diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f7785c480..3b0577d66 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,7 +5,6 @@ on: branches: - main - release/* - - feature/* - dev paths-ignore: - ".github/**" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3537dad73..e224d9d78 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,7 +5,6 @@ on: branches: - main - release/* - - feature/* - dev - develop/* pull_request: diff --git a/e2e/agent-conversation.spec.ts b/e2e/agent-conversation.spec.ts new file mode 100644 index 000000000..d47957da0 --- /dev/null +++ b/e2e/agent-conversation.spec.ts @@ -0,0 +1,204 @@ +import { expect } from "@playwright/test"; +import { test, makeTextSSE, makeToolCallSSE } from "./agent-fixtures"; +import { runInlineTestScript } from "./utils"; + +const TARGET_URL = "https://content-security-policy.com/"; + +test.describe("Agent Conversation API", () => { + test.setTimeout(300_000); + + test("basic chat — send message and receive text reply", async ({ context, extensionId, mockLLMResponse }) => { + mockLLMResponse(() => makeTextSSE("1+1等于2。")); + + const code = `// ==UserScript== +// @name Agent Basic Chat Test +// @namespace https://e2e.test +// @version 1.0.0 +// @description Test basic CAT.agent.conversation.chat() +// @author E2E +// @match ${TARGET_URL}* +// @grant CAT.agent.conversation +// ==/UserScript== + +(async () => { + let passed = 0; + let failed = 0; + function assert(name, condition) { + if (condition) { passed++; console.log("PASS: " + name); } + else { failed++; console.log("FAIL: " + name); } + } + + try { + const conv = await CAT.agent.conversation.create({ + system: "你是一个助手。", + }); + assert("conversation created", !!conv && !!conv.id); + + const reply = await conv.chat("1+1等于几?"); + assert("reply has content", !!reply.content); + assert("reply content correct", reply.content.includes("2")); + assert("reply has usage", !!reply.usage); + } catch (e) { + failed++; + console.log("ERROR: " + e.message); + } + + console.log("通过: " + passed + ", 失败: " + failed); +})(); +`; + + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + + console.log(`[agent-basic-chat] passed=${passed}, failed=${failed}`); + if (failed !== 0) console.log("[agent-basic-chat] logs:", logs.join("\n")); + expect(failed, "Some basic chat tests failed").toBe(0); + expect(passed, "No test results found").toBeGreaterThan(0); + }); + + test("tool calling — script-defined tools are invoked", async ({ context, extensionId, mockLLMResponse }) => { + let callCount = 0; + mockLLMResponse(() => { + callCount++; + // First call: LLM decides to call the tool + if (callCount === 1) { + return makeToolCallSSE([ + { + id: "call_1", + name: "get_weather", + arguments: JSON.stringify({ city: "北京" }), + }, + ]); + } + // Second call: after tool result, LLM gives final answer + return makeTextSSE("北京今天22度,多云。"); + }); + + const code = `// ==UserScript== +// @name Agent Tool Calling Test +// @namespace https://e2e.test +// @version 1.0.0 +// @description Test tool calling via CAT.agent.conversation +// @author E2E +// @match ${TARGET_URL}* +// @grant CAT.agent.conversation +// ==/UserScript== + +(async () => { + let passed = 0; + let failed = 0; + let toolCalled = false; + let toolArgs = null; + + function assert(name, condition) { + if (condition) { passed++; console.log("PASS: " + name); } + else { failed++; console.log("FAIL: " + name); } + } + + try { + const conv = await CAT.agent.conversation.create({ + system: "你是天气助手。", + tools: [ + { + name: "get_weather", + description: "获取天气", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "城市" }, + }, + required: ["city"], + }, + handler: async (args) => { + toolCalled = true; + toolArgs = args; + return { city: args.city, temperature: 22, condition: "多云" }; + }, + }, + ], + }); + + const reply = await conv.chat("北京天气怎么样?"); + assert("tool was called", toolCalled); + assert("tool received city arg", toolArgs && toolArgs.city === "北京"); + assert("final reply has content", !!reply.content); + assert("final reply mentions weather", reply.content.includes("22") || reply.content.includes("多云")); + } catch (e) { + failed++; + console.log("ERROR: " + e.message); + } + + console.log("通过: " + passed + ", 失败: " + failed); +})(); +`; + + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + + console.log(`[agent-tool-calling] passed=${passed}, failed=${failed}`); + if (failed !== 0) console.log("[agent-tool-calling] logs:", logs.join("\n")); + expect(failed, "Some tool calling tests failed").toBe(0); + expect(passed, "No test results found").toBeGreaterThan(0); + }); + + test("multi-turn conversation — context is preserved", async ({ context, extensionId, mockLLMResponse }) => { + let requestCount = 0; + let lastMessages: any[] = []; + + mockLLMResponse(({ messages }) => { + requestCount++; + lastMessages = messages; + if (requestCount === 1) { + return makeTextSSE("斐波那契数列是一个数列,每个数是前两个数之和。"); + } + return makeTextSSE("前5个数是:1, 1, 2, 3, 5。"); + }); + + const code = `// ==UserScript== +// @name Agent Multi-turn Test +// @namespace https://e2e.test +// @version 1.0.0 +// @description Test multi-turn conversation context preservation +// @author E2E +// @match ${TARGET_URL}* +// @grant CAT.agent.conversation +// ==/UserScript== + +(async () => { + let passed = 0; + let failed = 0; + function assert(name, condition) { + if (condition) { passed++; console.log("PASS: " + name); } + else { failed++; console.log("FAIL: " + name); } + } + + try { + const conv = await CAT.agent.conversation.create({ + system: "你是数学老师。", + }); + + const reply1 = await conv.chat("什么是斐波那契数列?"); + assert("first reply has content", !!reply1.content); + + const reply2 = await conv.chat("前5个数是什么?"); + assert("second reply has content", !!reply2.content); + assert("second reply has fibonacci numbers", reply2.content.includes("1") && reply2.content.includes("5")); + } catch (e) { + failed++; + console.log("ERROR: " + e.message); + } + + console.log("通过: " + passed + ", 失败: " + failed); +})(); +`; + + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + + console.log(`[agent-multi-turn] passed=${passed}, failed=${failed}`); + if (failed !== 0) console.log("[agent-multi-turn] logs:", logs.join("\n")); + expect(failed, "Some multi-turn tests failed").toBe(0); + expect(passed, "No test results found").toBeGreaterThan(0); + // Verify the mock received multiple requests (context was sent) + expect(requestCount, "Should have made 2 LLM requests").toBe(2); + // The second request should contain history messages + expect(lastMessages.length, "Second request should include conversation history").toBeGreaterThan(2); + }); +}); diff --git a/e2e/agent-error-handling.spec.ts b/e2e/agent-error-handling.spec.ts new file mode 100644 index 000000000..9dc26c32f --- /dev/null +++ b/e2e/agent-error-handling.spec.ts @@ -0,0 +1,145 @@ +import { expect } from "@playwright/test"; +import { test, makeTextSSE } from "./agent-fixtures"; +import { runInlineTestScript } from "./utils"; + +const TARGET_URL = "https://content-security-policy.com/"; + +test.describe("Agent Error Handling", () => { + test.setTimeout(300_000); + + test("LLM returns 500 then retries and succeeds", async ({ context, extensionId, mockLLMResponse }) => { + let callCount = 0; + mockLLMResponse(() => { + callCount++; + if (callCount === 1) { + // First call will be intercepted below as 500; but mockLLMResponse + // wraps to always return 200. So we use a different approach: + // Return valid response on second call + return makeTextSSE("重试后的回复"); + } + return makeTextSSE("重试后的回复"); + }); + + // Override the route to return 500 on first call, then succeed + let reqCount = 0; + await context.route("**/mock-llm.test/**", async (route) => { + reqCount++; + if (reqCount === 1) { + await route.fulfill({ + status: 500, + body: "Internal Server Error", + }); + return; + } + // Second call: return valid SSE + await route.fulfill({ + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + body: makeTextSSE("重试成功了!"), + }); + }); + + const code = `// ==UserScript== +// @name Agent Error Retry Test +// @namespace https://e2e.test +// @version 1.0.0 +// @description Test LLM 500 error retry +// @author E2E +// @match ${TARGET_URL}* +// @grant CAT.agent.conversation +// ==/UserScript== + +(async () => { + let passed = 0; + let failed = 0; + function assert(name, condition) { + if (condition) { passed++; console.log("PASS: " + name); } + else { failed++; console.log("FAIL: " + name); } + } + + try { + const conv = await CAT.agent.conversation.create({ + system: "你是助手。", + }); + assert("conversation created", !!conv && !!conv.id); + + const reply = await conv.chat("你好"); + assert("reply has content after retry", !!reply.content); + assert("reply content correct", reply.content.includes("重试成功")); + } catch (e) { + failed++; + console.log("ERROR: " + e.message); + } + + console.log("通过: " + passed + ", 失败: " + failed); +})(); +`; + + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 90_000); + + console.log(`[error-retry] passed=${passed}, failed=${failed}`); + if (failed !== 0) console.log("[error-retry] logs:", logs.join("\n")); + expect(failed, "Some error retry tests failed").toBe(0); + expect(passed, "No test results found").toBeGreaterThan(0); + }); + + // 401 auth error 已由单元测试覆盖(callLLM 内部重试 5 次需 ~90s,e2e 等待过久) + + test("conversation abort — conv.abort() cancels ongoing chat", async ({ context, extensionId, mockLLMResponse }) => { + // Use a slow response to give time for abort + mockLLMResponse(() => { + return makeTextSSE("这是一个很长的回复"); + }); + + const code = `// ==UserScript== +// @name Agent Abort Test +// @namespace https://e2e.test +// @version 1.0.0 +// @description Test conversation abort +// @author E2E +// @match ${TARGET_URL}* +// @grant CAT.agent.conversation +// ==/UserScript== + +(async () => { + let passed = 0; + let failed = 0; + function assert(name, condition) { + if (condition) { passed++; console.log("PASS: " + name); } + else { failed++; console.log("FAIL: " + name); } + } + + try { + const conv = await CAT.agent.conversation.create({ + system: "你是助手。", + }); + assert("conversation created", !!conv && !!conv.id); + + // Start a chat and verify it completes normally first + const reply1 = await conv.chat("第一条消息"); + assert("first chat works", !!reply1.content); + + // Verify the conversation object exists and has expected methods + assert("conv has chat method", typeof conv.chat === "function"); + assert("conv has id", typeof conv.id === "string"); + } catch (e) { + failed++; + console.log("ERROR: " + e.message); + } + + console.log("通过: " + passed + ", 失败: " + failed); +})(); +`; + + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 60_000); + + console.log(`[abort] passed=${passed}, failed=${failed}`); + if (failed !== 0) console.log("[abort] logs:", logs.join("\n")); + expect(failed, "Some abort tests failed").toBe(0); + expect(passed, "No test results found").toBeGreaterThan(0); + }); +}); diff --git a/e2e/agent-fixtures.ts b/e2e/agent-fixtures.ts new file mode 100644 index 000000000..18dc5ce5a --- /dev/null +++ b/e2e/agent-fixtures.ts @@ -0,0 +1,221 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { test as base, chromium, type BrowserContext, type Route } from "@playwright/test"; + +const pathToExtension = path.resolve(__dirname, "../dist/ext"); +const chromeArgs = [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`]; + +function getProxyOptions() { + const proxy = + process.env.E2E_PROXY || + process.env.https_proxy || + process.env.http_proxy || + process.env.HTTPS_PROXY || + process.env.HTTP_PROXY; + return proxy ? { proxy: { server: proxy } } : {}; +} + +/** OpenAI-compatible SSE response for plain text replies */ +export function makeTextSSE(content: string): string { + const lines = [ + `data: ${JSON.stringify({ choices: [{ delta: { role: "assistant", content }, index: 0 }] })}`, + `data: ${JSON.stringify({ choices: [{ delta: {}, index: 0, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 5 } })}`, + "data: [DONE]", + "", + ]; + return lines.join("\n\n"); +} + +/** OpenAI-compatible SSE response for tool_calls */ +export function makeToolCallSSE(toolCalls: Array<{ id: string; name: string; arguments: string }>): string { + const lines: string[] = []; + for (const tc of toolCalls) { + lines.push( + `data: ${JSON.stringify({ + choices: [ + { + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: tc.id, + type: "function", + function: { name: tc.name, arguments: "" }, + }, + ], + }, + index: 0, + }, + ], + })}` + ); + lines.push( + `data: ${JSON.stringify({ + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: tc.arguments } }], + }, + index: 0, + }, + ], + })}` + ); + } + lines.push( + `data: ${JSON.stringify({ + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + })}` + ); + lines.push("data: [DONE]"); + lines.push(""); + return lines.join("\n\n"); +} + +export type MockLLMHandler = (body: { messages: any[]; tools?: any[] }) => string; + +export type AgentFixtures = { + context: BrowserContext; + extensionId: string; + mockLLMResponse: (handler: MockLLMHandler) => void; +}; + +/** + * Agent test fixtures — 两阶段启动 + mock LLM + * + * Phase 1: 启动 → 启用 userScripts → 写入 mock model 配置 → 关闭 + * Phase 2: 重启(权限和配置已持久化到 userDataDir) + * + * 必须在 Phase 1 写入 model 配置,因为 Repo 层使用 enableCache(), + * Phase 2 的 SW 启动时会一次性加载 storage 到内存缓存。 + * 如果在 Phase 2 SW 启动后才通过 evaluate 写入 storage, + * 内存缓存不会更新,导致 "No model configured" 错误。 + */ +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-")); + + // Phase 1: 启用 userScripts + 写入 mock model 配置 + const ctx1 = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: ["--headless=new", ...chromeArgs], + }); + let [bg] = ctx1.serviceWorkers(); + if (!bg) bg = await ctx1.waitForEvent("serviceworker", { timeout: 30_000 }); + const extensionId = bg.url().split("/")[2]; + + // 启用 userScripts 权限 + const extPage = await ctx1.newPage(); + await extPage.goto("chrome://extensions/"); + await extPage.waitForLoadState("domcontentloaded"); + await extPage.waitForFunction(() => !!(chrome as any).developerPrivate, { timeout: 10_000 }); + await extPage.evaluate(async (id) => { + await (chrome as any).developerPrivate.updateExtensionConfiguration({ + extensionId: id, + userScriptsAccess: true, + }); + }, extensionId); + await extPage.close(); + + // 写入 mock model 配置到 storage(Phase 1 写入,Phase 2 SW 启动时会加载到缓存) + // userScripts 启用后 SW 可能重启,重新获取 + let currentBg = ctx1.serviceWorkers().find((w) => w.url().includes(extensionId)); + if (!currentBg) { + currentBg = await ctx1.waitForEvent("serviceworker", { timeout: 15_000 }); + } + await currentBg.evaluate(() => { + const modelConfig = { + id: "mock-model", + name: "Mock LLM", + provider: "openai", + apiBaseUrl: "https://mock-llm.test/v1", + apiKey: "test-key", + model: "mock-gpt", + }; + return new Promise((resolve) => { + chrome.storage.local.set( + { + "agent_model:mock-model": modelConfig, + "agent_model:__default__": "mock-model", + }, + () => resolve() + ); + }); + }); + + await ctx1.close(); + + // Phase 2: 重启,userScripts 权限和 model 配置已持久化 + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: ["--headless=new", ...chromeArgs], + ...getProxyOptions(), + }); + const [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 }); + }, + + extensionId: async ({ context }, use) => { + let [background] = context.serviceWorkers(); + if (!background) background = await context.waitForEvent("serviceworker"); + const extensionId = background.url().split("/")[2]; + + // 关闭首次使用引导 + const initPage = await context.newPage(); + await initPage.goto(`chrome-extension://${extensionId}/src/options.html`, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + await initPage.evaluate(() => localStorage.setItem("firstUse", "false")); + await initPage.close(); + + await use(extensionId); + }, + + mockLLMResponse: async ({ context }, use) => { + let currentHandler: MockLLMHandler = () => makeTextSSE("default mock response"); + + await context.route("**/mock-llm.test/**", async (route: Route) => { + const request = route.request(); + if (request.method() !== "POST") { + await route.fulfill({ status: 405, body: "Method not allowed" }); + return; + } + + let body: any; + try { + body = JSON.parse(request.postData() || "{}"); + } catch { + body = {}; + } + + const sseResponse = currentHandler({ + messages: body.messages || [], + tools: body.tools, + }); + + await route.fulfill({ + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + body: sseResponse, + }); + }); + + const setHandler = (handler: MockLLMHandler) => { + currentHandler = handler; + }; + + await use(setHandler); + }, +}); diff --git a/e2e/agent-skill.spec.ts b/e2e/agent-skill.spec.ts new file mode 100644 index 000000000..4eee5009a --- /dev/null +++ b/e2e/agent-skill.spec.ts @@ -0,0 +1,132 @@ +import { expect } from "@playwright/test"; +import { test, makeTextSSE, makeToolCallSSE } from "./agent-fixtures"; +import { runInlineTestScript } from "./utils"; + +const TARGET_URL = "https://content-security-policy.com/"; + +const SKILL_MD = `--- +name: greeting-skill +description: A skill for greeting people +--- +You are a greeting assistant. Use execute_skill_script to run the say_hello script. +`; + +const SAY_HELLO_CODE = ` +// ==SkillScript== +// @name say_hello +// @description Say hello to someone +// @param name string [required] Person's name +// ==/SkillScript== + +return "Hello, " + args.name + "! Welcome!"; +`.trim(); + +test.describe("Agent Skill System", () => { + test.setTimeout(300_000); + + test("Skill install + load_skill + execute_skill_script invocation", async ({ + context, + extensionId, + mockLLMResponse, + }) => { + let callCount = 0; + mockLLMResponse(({ tools: _tools }) => { + callCount++; + if (callCount === 1) { + // First call: LLM decides to load the skill + return makeToolCallSSE([ + { + id: "call_load", + name: "load_skill", + arguments: JSON.stringify({ skill_name: "greeting-skill" }), + }, + ]); + } + if (callCount === 2) { + // Second call: after skill loaded, LLM calls execute_skill_script + return makeToolCallSSE([ + { + id: "call_greet", + name: "execute_skill_script", + arguments: JSON.stringify({ skill: "greeting-skill", script: "say_hello", params: { name: "World" } }), + }, + ]); + } + // Third call: final text response + return makeTextSSE("工具返回了:Hello, World! Welcome!"); + }); + + const escapedSkillMd = JSON.stringify(SKILL_MD); + const escapedToolCode = JSON.stringify(SAY_HELLO_CODE); + + const code = `// ==UserScript== +// @name Agent Skill Test +// @namespace https://e2e.test +// @version 1.0.0 +// @description Test Skill install + load_skill + execute_skill_script +// @author E2E +// @match ${TARGET_URL}* +// @grant CAT.agent.skills +// @grant CAT.agent.conversation +// ==/UserScript== + +(async () => { + let passed = 0; + let failed = 0; + function assert(name, condition) { + if (condition) { passed++; console.log("PASS: " + name); } + else { failed++; console.log("FAIL: " + name); } + } + + try { + const skillMd = ${escapedSkillMd}; + const toolCode = ${escapedToolCode}; + + // Install the skill with its script + const skillRecord = await CAT.agent.skills.install( + skillMd, + [{ name: "say_hello.js", code: toolCode }] + ); + console.log("Skill installed: " + JSON.stringify(skillRecord)); + assert("skill installed", !!skillRecord); + assert("skill name correct", skillRecord.name === "greeting-skill"); + assert("skill has tool", skillRecord.toolNames && skillRecord.toolNames.includes("say_hello")); + + // Verify skill appears in list + const skills = await CAT.agent.skills.list(); + assert("skill in list", skills.some(s => s.name === "greeting-skill")); + + // Create conversation with skills enabled + const conv = await CAT.agent.conversation.create({ + system: "You are a greeting assistant.", + skills: ["greeting-skill"], + }); + assert("conversation created", !!conv && !!conv.id); + + // Chat — mock will trigger load_skill → then execute_skill_script → final text + const reply = await conv.chat("Please greet World"); + console.log("Reply: " + reply.content); + assert("reply has content", !!reply.content); + assert("reply mentions greeting", reply.content.includes("Hello") || reply.content.includes("World")); + + // Clean up + await CAT.agent.skills.remove("greeting-skill"); + const skillsAfter = await CAT.agent.skills.list(); + assert("skill removed", !skillsAfter.some(s => s.name === "greeting-skill")); + } catch (e) { + failed++; + console.log("ERROR: " + e.message + " " + (e.stack || "")); + } + + console.log("通过: " + passed + ", 失败: " + failed); +})(); +`; + + const { passed, failed, logs } = await runInlineTestScript(context, extensionId, code, TARGET_URL, 90_000); + + console.log(`[skill-integration] passed=${passed}, failed=${failed}`); + if (failed !== 0) console.log("[skill-integration] logs:", logs.join("\n")); + expect(failed, "Some skill integration tests failed").toBe(0); + expect(passed, "No test results found").toBeGreaterThan(0); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 7d613e7c1..c36245728 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,16 +1,35 @@ -import { test as base, chromium, type BrowserContext } from "@playwright/test"; +import fs from "fs"; +import os from "os"; import path from "path"; +import { test as base, chromium, type BrowserContext } from "@playwright/test"; + +const pathToExtension = path.resolve(__dirname, "../dist/ext"); + +function getProxyOptions() { + const proxy = + process.env.E2E_PROXY || + process.env.https_proxy || + process.env.http_proxy || + process.env.HTTPS_PROXY || + process.env.HTTP_PROXY; + return proxy ? { proxy: { server: proxy } } : {}; +} +const chromeArgs = [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`]; + +/** + * 简单启动 fixture — 不需要 userScripts 的测试使用 + */ export const test = base.extend<{ context: BrowserContext; extensionId: string; }>({ // eslint-disable-next-line no-empty-pattern context: async ({}, use) => { - const pathToExtension = path.resolve(__dirname, "../dist/ext"); const context = await chromium.launchPersistentContext("", { headless: false, - args: ["--headless=new", `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`], + args: ["--headless=new", ...chromeArgs], + ...getProxyOptions(), }); await use(context); await context.close(); @@ -37,3 +56,65 @@ export const test = base.extend<{ }); export const expect = test.expect; + +/** + * 两阶段启动 fixture — 需要 userScripts 权限的测试使用 + * + * Phase 1: 启动浏览器 → 启用 userScripts 权限 → 关闭 + * Phase 2: 重新启动浏览器(权限已持久化) + */ +export const testWithUserScripts = base.extend<{ + context: BrowserContext; + extensionId: string; +}>({ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-")); + + // Phase 1: 启用 userScripts 权限 + const ctx1 = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: ["--headless=new", ...chromeArgs], + }); + let [bg] = ctx1.serviceWorkers(); + 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.waitForFunction(() => !!(chrome as any).developerPrivate, { timeout: 10_000 }); + await extPage.evaluate(async (id) => { + await (chrome as any).developerPrivate.updateExtensionConfiguration({ + extensionId: id, + userScriptsAccess: true, + }); + }, extensionId); + await extPage.close(); + await ctx1.close(); + + // Phase 2: 重新启动,userScripts 权限已持久化 + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: ["--headless=new", ...chromeArgs], + ...getProxyOptions(), + }); + const [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 }); + }, + extensionId: async ({ context }, use) => { + let [background] = context.serviceWorkers(); + if (!background) background = await context.waitForEvent("serviceworker"); + const extensionId = background.url().split("/")[2]; + + const initPage = await context.newPage(); + await initPage.goto(`chrome-extension://${extensionId}/src/options.html`); + await initPage.waitForLoadState("domcontentloaded"); + await initPage.evaluate(() => localStorage.setItem("firstUse", "false")); + await initPage.close(); + + await use(extensionId); + }, +}); diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 5f9a55ff8..e31e1211d 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -1,158 +1,14 @@ -import fs from "fs"; -import path from "path"; -import os from "os"; -import { test as base, expect, chromium, type BrowserContext } from "@playwright/test"; -import { installScriptByCode } from "./utils"; - -const test = base.extend<{ - context: BrowserContext; - extensionId: string; -}>({ - // eslint-disable-next-line no-empty-pattern - context: async ({}, use) => { - const pathToExtension = path.resolve(__dirname, "../dist/ext"); - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-ext-")); - const chromeArgs = [`--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`]; - - // Phase 1: Enable user scripts permission - const ctx1 = await chromium.launchPersistentContext(userDataDir, { - headless: false, - args: ["--headless=new", ...chromeArgs], - }); - let [bg] = ctx1.serviceWorkers(); - 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"); - // 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, - userScriptsAccess: true, - }); - }, extensionId); - await extPage.close(); - await ctx1.close(); - - // Phase 2: Relaunch with user scripts enabled - const context = await chromium.launchPersistentContext(userDataDir, { - 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. - const [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 }); - }, - extensionId: async ({ context }, use) => { - let [background] = context.serviceWorkers(); - if (!background) background = await context.waitForEvent("serviceworker"); - const extensionId = background.url().split("/")[2]; - const initPage = await context.newPage(); - await initPage.goto(`chrome-extension://${extensionId}/src/options.html`); - await initPage.waitForLoadState("domcontentloaded"); - await initPage.evaluate(() => localStorage.setItem("firstUse", "false")); - await initPage.close(); - await use(extensionId); - }, -}); - -/** Strip SRI hashes and replace slow CDN with faster alternative */ -function patchScriptCode(code: string): string { - return code - .replace(/^(\/\/\s*@(?:require|resource)\s+.*?)#sha(?:256|384|512)[=-][^\s]+/gm, "$1") - .replace(/https:\/\/cdn\.jsdelivr\.net\/npm\//g, "https://unpkg.com/"); -} - -/** - * Auto-approve permission confirm dialogs opened by the extension. - * Listens for new pages matching confirm.html and clicks the - * "permanent allow all" button (type=4, allow=true). - */ -function autoApprovePermissions(context: BrowserContext): void { - context.on("page", async (page) => { - const url = page.url(); - if (!url.includes("confirm.html")) return; - - try { - await page.waitForLoadState("domcontentloaded"); - // Click the "permanent allow" button (4th success button = type=5 permanent allow this) - // The buttons in order are: allow_once(1), temporary_allow(3), permanent_allow(5) - // We want "permanent_allow" which is the 3rd success button - const successButtons = page.locator("button.arco-btn-status-success"); - await successButtons.first().waitFor({ timeout: 5_000 }); - // Find and click the last always-visible success button (permanent_allow, type=5) - // Button order: allow_once(type=1), temporary_allow(type=3), permanent_allow(type=5) - // Index 2 = permanent_allow (always visible) - const count = await successButtons.count(); - if (count >= 3) { - // permanent_allow is at index 2 - await successButtons.nth(2).click(); - } else { - // Fallback: click the last visible success button - await successButtons.last().click(); - } - console.log("[autoApprove] Permission approved on confirm page"); - } catch (e) { - console.log("[autoApprove] Failed to approve:", e); - } - }); -} - -/** Run a test script on the target page and collect console results */ -async function runTestScript( - context: BrowserContext, - extensionId: string, - scriptFile: string, - targetUrl: string, - timeoutMs: number -): Promise<{ passed: number; failed: number; logs: string[] }> { - let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); - code = patchScriptCode(code); - - await installScriptByCode(context, extensionId, code); - - // Start auto-approving permission dialogs - autoApprovePermissions(context); - - const page = await context.newPage(); - const logs: string[] = []; - let passed = -1; - let failed = -1; - - // 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) 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 }; -} +import { expect } from "@playwright/test"; +import { testWithUserScripts } from "./fixtures"; +import { runTestScript } from "./utils"; const TARGET_URL = "https://content-security-policy.com/"; -test.describe("GM API", () => { +testWithUserScripts.describe("GM API", () => { // Two-phase launch + script install + network fetches + permission dialogs - test.setTimeout(300_000); + testWithUserScripts.setTimeout(300_000); - test("GM_ sync API tests (gm_api_test.js)", async ({ context, extensionId }) => { + testWithUserScripts("GM_ sync API tests (gm_api_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript(context, extensionId, "gm_api_test.js", TARGET_URL, 90_000); console.log(`[gm_api_test] passed=${passed}, failed=${failed}`); @@ -163,7 +19,7 @@ test.describe("GM API", () => { expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); }); - test("GM.* async API tests (gm_api_async_test.js)", async ({ context, extensionId }) => { + testWithUserScripts("GM.* async API tests (gm_api_async_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript( context, extensionId, @@ -180,7 +36,7 @@ test.describe("GM API", () => { expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); }); - test("Content inject tests (inject_content_test.js)", async ({ context, extensionId }) => { + testWithUserScripts("Content inject tests (inject_content_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript( context, extensionId, @@ -197,7 +53,7 @@ test.describe("GM API", () => { expect(passed, "No test results found - script may not have run").toBeGreaterThan(0); }); - test("Unwrap scriptlet tests (unwrap_e2e_test.js)", async ({ context, extensionId }) => { + testWithUserScripts("Unwrap scriptlet tests (unwrap_e2e_test.js)", async ({ context, extensionId }) => { const { passed, failed, logs } = await runTestScript( context, extensionId, diff --git a/e2e/options.spec.ts b/e2e/options.spec.ts index 2901e4113..a4968a31c 100644 --- a/e2e/options.spec.ts +++ b/e2e/options.spec.ts @@ -35,20 +35,12 @@ 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 - await page - .locator(".arco-menu-item") - .filter({ hasText: /setting|设置/i }) - .first() - .click(); + // Click "Settings" / "设置" menu item (use .menu-setting to avoid matching agent_settings in submenu) + await page.locator(".menu-setting .arco-menu-item").click(); await expect(page).toHaveURL(/.*#\/setting/); // Navigate back to script list (home) - click the first menu item diff --git a/e2e/utils.ts b/e2e/utils.ts index d1494c557..4275dd6df 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -1,5 +1,89 @@ +import fs from "fs"; +import path from "path"; import type { BrowserContext, Page } from "@playwright/test"; +/** Strip SRI hashes and replace slow CDN with faster alternative */ +export function patchScriptCode(code: string): string { + return code + .replace(/^(\/\/\s*@(?:require|resource)\s+.*?)#sha(?:256|384|512)[=-][^\s]+/gm, "$1") + .replace(/https:\/\/cdn\.jsdelivr\.net\/npm\//g, "https://unpkg.com/"); +} + +/** + * Auto-approve permission confirm dialogs opened by the extension. + * Listens for new pages matching confirm.html and clicks the + * "permanent allow all" button (type=4, allow=true). + */ +export function autoApprovePermissions(context: BrowserContext): void { + context.on("page", async (page) => { + const url = page.url(); + if (!url.includes("confirm.html")) return; + + try { + await page.waitForLoadState("domcontentloaded"); + const successButtons = page.locator("button.arco-btn-status-success"); + await successButtons.first().waitFor({ timeout: 5_000 }); + const count = await successButtons.count(); + if (count >= 3) { + await successButtons.nth(2).click(); + } else { + await successButtons.last().click(); + } + console.log("[autoApprove] Permission approved on confirm page"); + } catch (e) { + console.log("[autoApprove] Failed to approve:", e); + } + }); +} + +/** Run a test script from example/tests/ on the target page and collect console results */ +export async function runTestScript( + context: BrowserContext, + extensionId: string, + scriptFile: string, + targetUrl: string, + timeoutMs: number +): Promise<{ passed: number; failed: number; logs: string[] }> { + let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8"); + code = patchScriptCode(code); + return runInlineTestScript(context, extensionId, code, targetUrl, timeoutMs); +} + +/** Run inline script code on the target page and collect console results */ +export async function runInlineTestScript( + context: BrowserContext, + extensionId: string, + code: string, + targetUrl: string, + timeoutMs: number +): Promise<{ passed: number; failed: number; logs: string[] }> { + await installScriptByCode(context, extensionId, code); + autoApprovePermissions(context); + + const page = await context.newPage(); + const logs: string[] = []; + let passed = -1; + let failed = -1; + + 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) resolve(); + }); + }); + + await page.goto(targetUrl, { waitUntil: "domcontentloaded" }); + await Promise.race([resultReady, page.waitForTimeout(timeoutMs)]); + + await page.close(); + return { passed, failed, logs }; +} + /** Open the options page and wait for it to load */ export async function openOptionsPage(context: BrowserContext, extensionId: string): Promise { const page = await context.newPage(); @@ -39,9 +123,11 @@ export async function installScriptByCode(context: BrowserContext, extensionId: // 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 to focus and wait for the cursor to appear (confirms editor is interactive) + // Click to focus editor; headless Chrome 下光标可能不会变为 visible,改用 focused 状态判断 await page.locator(".monaco-editor .view-lines").click(); - await page.locator(".cursors-layer .cursor").waitFor({ timeout: 5_000 }); + // 等待编辑器获得焦点(textarea 获得 focus 即表示可交互) + await page.locator(".monaco-editor textarea.inputarea").waitFor({ state: "attached", timeout: 5_000 }); + await page.locator(".monaco-editor textarea.inputarea").focus(); // Select all existing content await page.keyboard.press("ControlOrMeta+a"); // Capture current content fingerprint, then paste replacement diff --git a/example/agent/README.md b/example/agent/README.md new file mode 100644 index 000000000..7031b8fdf --- /dev/null +++ b/example/agent/README.md @@ -0,0 +1,5 @@ +# Agent 示例 + +Agent 相关的 Skills、Skill Scripts 和使用示例已迁移到独立仓库: + +- **GitHub**: https://github.com/scriptscat/skills diff --git a/package.json b/package.json index d9b7b274d..89ca6ddae 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "scripts": { "preinstall": "pnpm dlx only-allow pnpm", "prepare": "husky", - "test": "vitest --test-timeout=500 --no-coverage --isolate=false --reporter=verbose", - "test:ci": "vitest run --test-timeout=500 --no-coverage --isolate=false --reporter=default --reporter.summary=false", + "test": "vitest --test-timeout=500 --no-coverage --reporter=verbose", + "test:ci": "vitest run --test-timeout=500 --no-coverage --reporter=default --reporter.summary=false", "coverage": "vitest run --coverage", "coverage:ci": "vitest run --coverage --silent --reporter=default --reporter.default.summary=false", "typecheck": "tsc --noEmit", @@ -43,6 +43,7 @@ "eslint-linter-browserify": "9.26.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^5.5.8", + "highlight.js": "^11.11.1", "i18next": "^23.16.4", "monaco-editor": "^0.52.2", "react": "^18.3.1", @@ -51,7 +52,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", diff --git a/packages/eslint/compat-grant.js b/packages/eslint/compat-grant.js index 6b90fa7a2..f4cce51b6 100644 --- a/packages/eslint/compat-grant.js +++ b/packages/eslint/compat-grant.js @@ -7,6 +7,11 @@ const compatMap = { CAT_registerMenuInput: [{ type: "scriptcat", versionConstraint: ">=0.17.0-beta.2" }], CAT_unregisterMenuInput: [{ type: "scriptcat", versionConstraint: ">=0.17.0-beta.2" }], CAT_scriptLoaded: [{ type: "scriptcat", versionConstraint: ">=1.1.0-beta" }], + "CAT.agent.conversation": [{ 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" }], + "CAT.agent.opfs": [{ type: "scriptcat", versionConstraint: ">=1.4.0-beta" }], ...compat_grant.compatMap, }; diff --git a/packages/eslint/linter-config.ts b/packages/eslint/linter-config.ts index d6b57a1c4..361eadcc8 100644 --- a/packages/eslint/linter-config.ts +++ b/packages/eslint/linter-config.ts @@ -17,6 +17,7 @@ const config = { CAT_registerMenuInput: "readonly", CAT_unregisterMenuInput: "readonly", CAT_scriptLoaded: "readonly", + CAT: "readonly", }, rules: { "constructor-super": ["error"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9668d48ea..90b7aadb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: fast-xml-parser: specifier: ^5.5.8 version: 5.5.8 + 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 @@ -1227,6 +1239,9 @@ packages: '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1236,6 +1251,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==} @@ -1254,6 +1272,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==} @@ -1266,9 +1287,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==} @@ -1322,6 +1349,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==} @@ -1384,6 +1417,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: @@ -1712,6 +1748,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==} @@ -1800,6 +1839,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'} @@ -1808,6 +1850,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==} @@ -1844,6 +1898,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==} @@ -2008,6 +2065,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==} @@ -2066,6 +2126,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==} @@ -2191,6 +2254,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 @@ -2278,6 +2345,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==} @@ -2314,6 +2384,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==} @@ -2547,6 +2620,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==} @@ -2563,6 +2652,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==} @@ -2655,6 +2747,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'} @@ -2667,6 +2762,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'} @@ -2712,6 +2813,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} @@ -2733,6 +2837,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'} @@ -2764,6 +2871,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==} @@ -2945,10 +3056,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==} @@ -2973,6 +3090,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'} @@ -2980,6 +3100,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==} @@ -3006,6 +3171,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'} @@ -3213,6 +3462,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'} @@ -3337,6 +3589,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==} @@ -3448,6 +3703,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'} @@ -3498,6 +3759,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'} @@ -3701,6 +3977,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==} @@ -3750,6 +4029,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'} @@ -3761,6 +4043,12 @@ packages: strnum@2.2.1: resolution: {integrity: sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==} + 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'} @@ -3875,6 +4163,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'} @@ -3964,6 +4258,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'} @@ -4042,6 +4357,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} @@ -4292,6 +4613,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': {} @@ -5175,6 +5499,10 @@ snapshots: '@types/crypto-js@4.2.2': {} + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/eslint-scope@3.7.7': @@ -5189,6 +5517,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': @@ -5213,6 +5545,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': @@ -5223,8 +5559,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 @@ -5284,6 +5626,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 @@ -5381,6 +5727,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.3))': dependencies: '@unocss/core': 66.5.4 @@ -5870,6 +6218,8 @@ snapshots: b-validate@1.5.3: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} base-64@1.0.0: {} @@ -5974,6 +6324,8 @@ snapshots: caniuse-lite@1.0.30001727: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@4.1.2: @@ -5981,6 +6333,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: {} @@ -6024,6 +6384,8 @@ snapshots: colorette@2.0.20: {} + comma-separated-tokens@2.0.3: {} + commander@2.20.3: {} commander@7.2.0: {} @@ -6177,6 +6539,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: {} @@ -6220,6 +6586,10 @@ snapshots: detect-node@2.1.0: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dexie@4.0.10: {} diff@4.0.2: {} @@ -6445,6 +6815,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) @@ -6567,6 +6939,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 @@ -6624,6 +6998,8 @@ snapshots: exsolve@1.0.7: {} + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -6861,6 +7237,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: @@ -6880,6 +7293,8 @@ snapshots: dependencies: void-elements: 3.1.0 + html-url-attributes@3.0.1: {} + http-deceiver@1.2.7: {} http-errors@1.8.1: @@ -6978,6 +7393,8 @@ snapshots: inherits@2.0.4: {} + inline-style-parser@0.2.7: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -6988,6 +7405,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 @@ -7034,6 +7458,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: {} @@ -7050,6 +7476,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 @@ -7071,6 +7499,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: @@ -7269,10 +7699,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: {} @@ -7295,6 +7733,8 @@ snapshots: make-error@1.3.6: {} + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} md5@2.3.0: @@ -7303,6 +7743,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: {} @@ -7333,6 +7926,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.13 + 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 @@ -7527,6 +8311,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 @@ -7635,6 +8429,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: @@ -7753,6 +8549,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 @@ -7823,6 +8637,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: {} @@ -8093,6 +8949,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 @@ -8176,6 +9034,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 @@ -8184,6 +9047,14 @@ snapshots: strnum@2.2.1: {} + 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 @@ -8287,6 +9158,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 @@ -8398,6 +9273,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.3)): 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.3)) @@ -8476,6 +9389,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.3): dependencies: esbuild: 0.25.5 @@ -8768,3 +9691,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/cache_key.ts b/src/app/cache_key.ts index 74d3874af..ae7d5df2e 100644 --- a/src/app/cache_key.ts +++ b/src/app/cache_key.ts @@ -4,3 +4,4 @@ export const CACHE_KEY_SCRIPT_INFO = "scriptInfo:"; // 加载脚本信息时的 export const CACHE_KEY_FAVICON = "favicon:"; export const CACHE_KEY_SET_VALUE = "setValue:"; export const CACHE_KEY_PERMISSION = "permission:"; +export const CACHE_KEY_SKILL_INSTALL = "skillInstall:"; // Skill ZIP 待安装数据缓存 diff --git a/src/app/repo/agent_chat.test.ts b/src/app/repo/agent_chat.test.ts new file mode 100644 index 000000000..f3b1e2c6e --- /dev/null +++ b/src/app/repo/agent_chat.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AgentChatRepo } from "./agent_chat"; + +// Mock OPFS 文件系统 +function createMockOPFS() { + function createMockWritable() { + let data: any = null; + return { + write: vi.fn(async (content: any) => { + data = content; + }), + close: vi.fn(async () => {}), + getData: () => data, + }; + } + + function createMockFileHandle(name: string, dir: Map) { + return { + kind: "file" as const, + getFile: vi.fn(async () => { + const content = dir.get(name); + if (content instanceof Blob) return content; + if (content instanceof ArrayBuffer) return new Blob([content]); + if (content instanceof Uint8Array) return new Blob([content.buffer as ArrayBuffer]); + if (typeof content === "string") return new Blob([content], { type: "application/octet-stream" }); + return new Blob([""], { type: "application/octet-stream" }); + }), + createWritable: vi.fn(async () => { + const writable = createMockWritable(); + const origClose = writable.close; + writable.close = vi.fn(async () => { + const written = writable.getData(); + dir.set(name, written); + await origClose(); + }); + return writable; + }), + }; + } + + function createMockDirHandle(store: Map): any { + return { + kind: "directory" as const, + getDirectoryHandle: vi.fn(async (name: string, opts?: { create?: boolean }) => { + if (!store.has("__dir__" + name)) { + if (opts?.create) { + store.set("__dir__" + name, new Map()); + } else { + throw new Error("Not found"); + } + } + return createMockDirHandle(store.get("__dir__" + name)); + }), + getFileHandle: vi.fn(async (name: string, opts?: { create?: boolean }) => { + if (!store.has(name) && !opts?.create) { + throw new Error("Not found"); + } + if (!store.has(name)) { + store.set(name, ""); + } + return createMockFileHandle(name, store); + }), + removeEntry: vi.fn(async (name: string) => { + store.delete(name); + store.delete("__dir__" + name); + }), + [Symbol.asyncIterator]: async function* () { + for (const [key] of store) { + if (!key.startsWith("__dir__")) { + yield [key, { kind: "file" }]; + } + } + }, + }; + } + + const rootStore = new Map(); + const mockRoot = createMockDirHandle(rootStore); + + // 只 mock navigator.storage,避免展开 navigator 丢失 getter 属性(如 userAgent) + // 在 isolate=false 下破坏全局 navigator 会导致后续测试 react-dom 初始化失败 + Object.defineProperty(navigator, "storage", { + value: { + getDirectory: vi.fn(async () => mockRoot), + }, + configurable: true, + writable: true, + }); + + return { rootStore, mockRoot }; +} + +// 在 mock store 中按路径导航/创建目录 +function navigateDir(rootStore: Map, ...path: string[]): Map { + let current = rootStore; + for (const seg of path) { + const key = "__dir__" + seg; + if (!current.has(key)) { + current.set(key, new Map()); + } + current = current.get(key); + } + return current; +} + +describe("AgentChatRepo 附件存储", () => { + let repo: AgentChatRepo; + let rootStore: Map; + + beforeEach(() => { + const mock = createMockOPFS(); + rootStore = mock.rootStore; + repo = new AgentChatRepo(); + }); + + it("saveAttachment 应保存 data URL 字符串并返回大小", async () => { + const dataUrl = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="; + const size = await repo.saveAttachment("att-1", dataUrl); + + // base64 "/9j/4AAQSkZJRg==" 解码为 10 字节 + expect(size).toBeGreaterThan(0); + }); + + it("saveAttachment 应保存 Blob 数据并返回大小", async () => { + const blob = new Blob(["hello world"], { type: "text/plain" }); + const size = await repo.saveAttachment("att-2", blob); + + expect(size).toBe(blob.size); + }); + + it("saveAttachment 应存储到 workspace/uploads 路径", async () => { + await repo.saveAttachment("att-ws", new Blob(["workspace data"])); + + // 验证新路径存在: agents/workspace/uploads/att-ws + const uploadsDir = navigateDir(rootStore, "agents", "workspace", "uploads"); + expect(uploadsDir.has("att-ws")).toBe(true); + }); + + it("getAttachment 应返回已保存的附件", async () => { + const blob = new Blob(["test data"], { type: "text/plain" }); + await repo.saveAttachment("att-3", blob); + + const result = await repo.getAttachment("att-3"); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Blob); + }); + + it("getAttachment 不存在的附件应返回 null", async () => { + const result = await repo.getAttachment("nonexistent"); + + expect(result).toBeNull(); + }); + + it("getAttachment 应能回退读取旧路径的附件", async () => { + // 手动在旧路径写入附件数据: agents/conversations/attachments/{id} + const attachDir = navigateDir(rootStore, "agents", "conversations", "attachments"); + attachDir.set("old-att", new Blob(["old path data"])); + + const result = await repo.getAttachment("old-att"); + + expect(result).not.toBeNull(); + expect(result).toBeInstanceOf(Blob); + const text = await result!.text(); + expect(text).toBe("old path data"); + }); + + it("deleteAttachment 应删除已保存的附件", async () => { + const blob = new Blob(["data"], { type: "text/plain" }); + await repo.saveAttachment("att-4", blob); + + await repo.deleteAttachment("att-4"); + + const result = await repo.getAttachment("att-4"); + expect(result).toBeNull(); + }); + + it("deleteAttachment 应同时清理新旧路径", async () => { + // 在新路径保存 + await repo.saveAttachment("att-both", new Blob(["new"])); + // 在旧路径也放一份 + const attachDir = navigateDir(rootStore, "agents", "conversations", "attachments"); + attachDir.set("att-both", new Blob(["old"])); + + await repo.deleteAttachment("att-both"); + + // 新旧路径都应被清理 + const uploadsDir = navigateDir(rootStore, "agents", "workspace", "uploads"); + expect(uploadsDir.has("att-both")).toBe(false); + expect(attachDir.has("att-both")).toBe(false); + }); + + it("deleteAttachments 应批量删除附件", async () => { + await repo.saveAttachment("att-a", new Blob(["a"])); + await repo.saveAttachment("att-b", new Blob(["b"])); + await repo.saveAttachment("att-c", new Blob(["c"])); + + await repo.deleteAttachments(["att-a", "att-c"]); + + expect(await repo.getAttachment("att-a")).toBeNull(); + expect(await repo.getAttachment("att-b")).not.toBeNull(); + expect(await repo.getAttachment("att-c")).toBeNull(); + }); + + it("saveAttachment 纯文本(非 data URL)应作为 octet-stream 存储", async () => { + const size = await repo.saveAttachment("att-5", "plain text content"); + expect(size).toBeGreaterThan(0); + }); + + it("deleteConversation 应清理关联的附件", async () => { + // 先保存会话和消息(含附件) + const convId = "conv-1"; + await repo.saveConversation({ + id: convId, + title: "Test", + modelId: "m1", + createtime: Date.now(), + updatetime: Date.now(), + }); + await repo.saveMessages(convId, [ + { + id: "msg-1", + conversationId: convId, + role: "assistant", + content: "", + toolCalls: [ + { + id: "tc-1", + name: "screenshot", + arguments: "{}", + attachments: [ + { id: "att-del-1", type: "image", name: "img.jpg", mimeType: "image/jpeg" }, + { id: "att-del-2", type: "file", name: "file.zip", mimeType: "application/zip" }, + ], + }, + ], + createtime: Date.now(), + }, + ]); + + // 保存附件数据 + await repo.saveAttachment("att-del-1", new Blob(["img"])); + await repo.saveAttachment("att-del-2", new Blob(["zip"])); + + // 删除会话 + await repo.deleteConversation(convId); + + // 附件应被清理 + expect(await repo.getAttachment("att-del-1")).toBeNull(); + expect(await repo.getAttachment("att-del-2")).toBeNull(); + }); +}); diff --git a/src/app/repo/agent_chat.ts b/src/app/repo/agent_chat.ts new file mode 100644 index 000000000..b91d1da8d --- /dev/null +++ b/src/app/repo/agent_chat.ts @@ -0,0 +1,186 @@ +import type { Conversation, ChatMessage } from "@App/app/service/agent/core/types"; +import type { Task } from "@App/app/service/agent/core/tools/task_tools"; +import { OPFSRepo } from "./opfs_repo"; +import { writeWorkspaceFile, getWorkspaceRoot, getDirectory } from "@App/app/service/agent/core/opfs_helpers"; + +const CONVERSATIONS_FILE = "conversations.json"; +const MESSAGES_DIR = "data"; +const ATTACHMENTS_DIR = "attachments"; +const TASKS_DIR = "tasks"; + +// 目录结构:agents/conversations/ +// agents/conversations/conversations.json - 会话列表 +// agents/conversations/data/{id}.json - 每个会话的消息 +// agents/workspace/uploads/{id} - 附件二进制数据(LLM 可通过 opfs_read 访问) +// agents/conversations/attachments/{id} - 旧路径(兼容读取) +export class AgentChatRepo extends OPFSRepo { + constructor() { + super("conversations"); + } + + // 获取所有会话列表 + async listConversations(): Promise { + return this.readJsonFile(CONVERSATIONS_FILE, []); + } + + // 保存/更新会话 + async saveConversation(conversation: Conversation): Promise { + const conversations = await this.readJsonFile(CONVERSATIONS_FILE, []); + const index = conversations.findIndex((c) => c.id === conversation.id); + if (index >= 0) { + conversations[index] = conversation; + } else { + conversations.unshift(conversation); + } + await this.writeJsonFile(CONVERSATIONS_FILE, conversations); + } + + // 删除会话及其消息和附件 + async deleteConversation(id: string): Promise { + // 清理会话关联的附件 + const messages = await this.getMessages(id); + const attachmentIds: string[] = []; + for (const msg of messages) { + // 扫描 toolCalls 中的附件 + if (msg.toolCalls) { + for (const tc of msg.toolCalls) { + if (tc.attachments) { + for (const att of tc.attachments) { + attachmentIds.push(att.id); + } + } + } + } + // 扫描 ContentBlock[] 中的附件 + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type !== "text" && "attachmentId" in block) { + attachmentIds.push(block.attachmentId); + } + } + } + } + if (attachmentIds.length > 0) { + await this.deleteAttachments(attachmentIds); + } + + const conversations = await this.readJsonFile(CONVERSATIONS_FILE, []); + const filtered = conversations.filter((c) => c.id !== id); + await this.writeJsonFile(CONVERSATIONS_FILE, filtered); + // 删除对应消息文件 + const messagesDir = await this.getChildDir(MESSAGES_DIR); + await this.deleteFile(`${id}.json`, messagesDir); + // 删除关联的任务数据 + await this.deleteTasks(id).catch(() => {}); + } + + // 获取指定会话的所有消息 + async getMessages(conversationId: string): Promise { + const messagesDir = await this.getChildDir(MESSAGES_DIR); + return this.readJsonFile(`${conversationId}.json`, [], messagesDir); + } + + // 追加消息 + async appendMessage(message: ChatMessage): Promise { + const messagesDir = await this.getChildDir(MESSAGES_DIR); + const messages = await this.readJsonFile(`${message.conversationId}.json`, [], messagesDir); + messages.push(message); + await this.writeJsonFile(`${message.conversationId}.json`, messages, messagesDir); + } + + // 更新消息(按 id 匹配) + async updateMessage(message: ChatMessage): Promise { + const messagesDir = await this.getChildDir(MESSAGES_DIR); + const messages = await this.readJsonFile(`${message.conversationId}.json`, [], messagesDir); + const index = messages.findIndex((m) => m.id === message.id); + if (index >= 0) { + messages[index] = message; + await this.writeJsonFile(`${message.conversationId}.json`, messages, messagesDir); + } + } + + // 保存整个消息列表(用于批量更新) + async saveMessages(conversationId: string, messages: ChatMessage[]): Promise { + const messagesDir = await this.getChildDir(MESSAGES_DIR); + await this.writeJsonFile(`${conversationId}.json`, messages, messagesDir); + } + + // ---- 附件存储 ---- + // 新路径: agents/workspace/uploads/{id}(LLM 可通过 opfs_read 访问) + // 旧路径: agents/conversations/attachments/{id}(兼容读取) + + // 保存附件数据到 workspace/uploads(支持 base64/data URL 字符串或 Blob) + async saveAttachment(id: string, data: string | Blob): Promise { + const result = await writeWorkspaceFile(`uploads/${id}`, data); + return result.size; + } + + // 读取附件数据为 Blob(先查 workspace 新路径,fallback 旧路径) + async getAttachment(id: string): Promise { + // 新路径: agents/workspace/uploads/{id} + try { + const workspace = await getWorkspaceRoot(); + const dir = await getDirectory(workspace, "uploads"); + return await (await dir.getFileHandle(id)).getFile(); + } catch { + // 新路径不存在,尝试旧路径 + } + // 旧路径回退: agents/conversations/attachments/{id} + try { + const dir = await this.getChildDir(ATTACHMENTS_DIR); + return await (await dir.getFileHandle(id)).getFile(); + } catch { + return null; + } + } + + // 删除单个附件(同时清理新旧路径) + async deleteAttachment(id: string): Promise { + // 新路径: agents/workspace/uploads/{id} + try { + const workspace = await getWorkspaceRoot(); + const dir = await getDirectory(workspace, "uploads"); + await dir.removeEntry(id); + } catch { + // 新路径不存在则忽略 + } + // 旧路径: agents/conversations/attachments/{id} + try { + const dir = await this.getChildDir(ATTACHMENTS_DIR); + await dir.removeEntry(id); + } catch { + // 旧路径不存在则忽略 + } + } + + // 删除会话关联的所有附件(需传入附件 ID 列表) + async deleteAttachments(ids: string[]): Promise { + for (const id of ids) { + await this.deleteAttachment(id); + } + } + + // ---- 任务 (task_tools) 存储 ---- + + // 获取会话关联的任务列表 + async getTasks(conversationId: string): Promise { + const tasksDir = await this.getChildDir(TASKS_DIR); + return this.readJsonFile(`${conversationId}.json`, [], tasksDir); + } + + // 保存会话关联的任务列表 + async saveTasks(conversationId: string, tasks: Task[]): Promise { + const tasksDir = await this.getChildDir(TASKS_DIR); + await this.writeJsonFile(`${conversationId}.json`, tasks, tasksDir); + } + + // 删除会话关联的任务 + async deleteTasks(conversationId: string): Promise { + const tasksDir = await this.getChildDir(TASKS_DIR); + await this.deleteFile(`${conversationId}.json`, tasksDir); + } +} + +// 模块级单例:AgentChatRepo 是 OPFS 的无状态薄包装,无需每处 new。 +// 子服务直接 import 使用,测试通过 vi.mock 替换整个模块。 +export const agentChatRepo = new AgentChatRepo(); diff --git a/src/app/repo/agent_model.ts b/src/app/repo/agent_model.ts new file mode 100644 index 000000000..aa6525842 --- /dev/null +++ b/src/app/repo/agent_model.ts @@ -0,0 +1,67 @@ +import type { AgentModelConfig } from "@App/app/service/agent/core/types"; +import { Repo, loadCache } from "./repo"; + +const DEFAULT_MODEL_KEY = "agent_model:__default__"; +const SUMMARY_MODEL_KEY = "agent_model:__summary__"; + +// 使用 chrome.storage.local 存储 Agent 模型配置 +export class AgentModelRepo extends Repo { + constructor() { + super("agent_model:"); + this.enableCache(); + } + + // 获取所有模型(排除 __default__ / __summary__ 等内部 key) + async listModels(): Promise { + return this.find((key) => !key.startsWith(`${this.prefix}__`)); + } + + // 获取指定模型 + async getModel(id: string): Promise { + return this.get(id); + } + + // 保存模型 + async saveModel(model: AgentModelConfig): Promise { + await this._save(model.id, model); + } + + // 删除模型 + async removeModel(id: string): Promise { + await this.delete(id); + } + + // 获取默认模型 ID(通过缓存层读取,避免绕过缓存导致不一致) + async getDefaultModelId(): Promise { + const cache = await loadCache(); + return (cache[DEFAULT_MODEL_KEY] as string) || ""; + } + + // 设置默认模型 ID(同时更新缓存和 storage) + async setDefaultModelId(id: string): Promise { + const cache = await loadCache(); + cache[DEFAULT_MODEL_KEY] = id; + return new Promise((resolve) => { + chrome.storage.local.set({ [DEFAULT_MODEL_KEY]: id }, () => { + resolve(); + }); + }); + } + + // 获取摘要模型 ID + async getSummaryModelId(): Promise { + const cache = await loadCache(); + return (cache[SUMMARY_MODEL_KEY] as string) || ""; + } + + // 设置摘要模型 ID + async setSummaryModelId(id: string): Promise { + const cache = await loadCache(); + cache[SUMMARY_MODEL_KEY] = id; + return new Promise((resolve) => { + chrome.storage.local.set({ [SUMMARY_MODEL_KEY]: id }, () => { + resolve(); + }); + }); + } +} diff --git a/src/app/repo/agent_task.test.ts b/src/app/repo/agent_task.test.ts new file mode 100644 index 000000000..15044486c --- /dev/null +++ b/src/app/repo/agent_task.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { AgentTaskRepo, AgentTaskRunRepo } from "./agent_task"; +import type { AgentTask, AgentTaskRun } from "@App/app/service/agent/core/types"; + +// Mock OPFS 文件系统 +function createMockOPFS() { + function createMockWritable() { + let data: any = null; + return { + write: vi.fn(async (content: any) => { + data = content; + }), + close: vi.fn(async () => {}), + getData: () => data, + }; + } + + function createMockFileHandle(name: string, dir: Map) { + return { + kind: "file" as const, + getFile: vi.fn(async () => { + const content = dir.get(name); + if (typeof content === "string") return new Blob([content], { type: "application/json" }); + return new Blob([""], { type: "application/json" }); + }), + createWritable: vi.fn(async () => { + const writable = createMockWritable(); + const origClose = writable.close; + writable.close = vi.fn(async () => { + const written = writable.getData(); + dir.set(name, written); + await origClose(); + }); + return writable; + }), + }; + } + + function createMockDirHandle(store: Map): any { + return { + kind: "directory" as const, + getDirectoryHandle: vi.fn(async (name: string, opts?: { create?: boolean }) => { + if (!store.has("__dir__" + name)) { + if (opts?.create) { + store.set("__dir__" + name, new Map()); + } else { + throw new Error("Not found"); + } + } + return createMockDirHandle(store.get("__dir__" + name)); + }), + getFileHandle: vi.fn(async (name: string, opts?: { create?: boolean }) => { + if (!store.has(name) && !opts?.create) { + throw new Error("Not found"); + } + if (!store.has(name)) { + store.set(name, ""); + } + return createMockFileHandle(name, store); + }), + removeEntry: vi.fn(async (name: string) => { + store.delete(name); + store.delete("__dir__" + name); + }), + }; + } + + const rootStore = new Map(); + const mockRoot = createMockDirHandle(rootStore); + + Object.defineProperty(navigator, "storage", { + value: { + getDirectory: vi.fn(async () => mockRoot), + }, + configurable: true, + writable: true, + }); +} + +function makeTask(overrides: Partial = {}): AgentTask { + return { + id: "task-1", + name: "测试任务", + crontab: "0 9 * * *", + mode: "internal", + enabled: true, + notify: false, + createtime: Date.now(), + updatetime: Date.now(), + ...overrides, + }; +} + +function makeRun(overrides: Partial = {}): AgentTaskRun { + return { + id: "run-1", + taskId: "task-1", + starttime: Date.now(), + status: "running", + ...overrides, + }; +} + +describe("AgentTaskRepo", () => { + let repo: AgentTaskRepo; + + beforeEach(() => { + createMockOPFS(); + repo = new AgentTaskRepo(); + }); + + it("saveTask / getTask CRUD", async () => { + const task = makeTask({ id: "crud-task" }); + await repo.saveTask(task); + const result = await repo.getTask("crud-task"); + expect(result).toBeDefined(); + expect(result!.name).toBe("测试任务"); + }); + + it("listTasks 返回所有任务", async () => { + await repo.saveTask(makeTask({ id: "list-a", name: "A" })); + await repo.saveTask(makeTask({ id: "list-b", name: "B" })); + const list = await repo.listTasks(); + expect(list.length).toBeGreaterThanOrEqual(2); + const names = list.map((t) => t.name); + expect(names).toContain("A"); + expect(names).toContain("B"); + }); + + it("removeTask 删除任务", async () => { + const task = makeTask({ id: "t-del" }); + await repo.saveTask(task); + await repo.removeTask("t-del"); + const result = await repo.getTask("t-del"); + expect(result).toBeUndefined(); + }); + + it("removeTask 同时清理关联的 runs", async () => { + const taskId = "t-clean"; + await repo.saveTask(makeTask({ id: taskId })); + const runRepo = new AgentTaskRunRepo(); + await runRepo.appendRun(makeRun({ id: "r1", taskId, starttime: 1000 })); + await runRepo.appendRun(makeRun({ id: "r2", taskId, starttime: 2000 })); + + await repo.removeTask(taskId); + + const runs = await runRepo.listRuns(taskId); + expect(runs).toHaveLength(0); + }); +}); + +describe("AgentTaskRunRepo", () => { + let repo: AgentTaskRunRepo; + + beforeEach(() => { + createMockOPFS(); + repo = new AgentTaskRunRepo(); + }); + + it("appendRun / listRuns 按 starttime 降序", async () => { + const taskId = "task-run-test"; + await repo.appendRun(makeRun({ id: "r-a", taskId, starttime: 1000 })); + await repo.appendRun(makeRun({ id: "r-b", taskId, starttime: 2000 })); + await repo.appendRun(makeRun({ id: "r-c", taskId, starttime: 3000 })); + + const runs = await repo.listRuns(taskId); + expect(runs.length).toBe(3); + // 按 starttime 降序(最新在前) + expect(runs[0].id).toBe("r-c"); + expect(runs[1].id).toBe("r-b"); + expect(runs[2].id).toBe("r-a"); + }); + + it("listRuns 限制返回条数", async () => { + const taskId = "task-limit"; + for (let i = 0; i < 5; i++) { + await repo.appendRun(makeRun({ id: `rl-${i}`, taskId, starttime: i * 1000 })); + } + const runs = await repo.listRuns(taskId, 3); + expect(runs.length).toBe(3); + }); + + it("clearRuns 清理指定任务的运行历史", async () => { + const taskId = "task-clear"; + await repo.appendRun(makeRun({ id: "rc-1", taskId })); + await repo.appendRun(makeRun({ id: "rc-2", taskId })); + // 其他任务的 run 不受影响 + await repo.appendRun(makeRun({ id: "rc-other", taskId: "other-task" })); + + await repo.clearRuns(taskId); + + const runs = await repo.listRuns(taskId); + expect(runs).toHaveLength(0); + + const otherRuns = await repo.listRuns("other-task"); + expect(otherRuns).toHaveLength(1); + }); + + it("updateRun 更新运行状态", async () => { + await repo.appendRun(makeRun({ id: "r-upd", taskId: "t-upd" })); + await repo.updateRun("t-upd", "r-upd", { status: "success", endtime: 99999 }); + const runs = await repo.listRuns("t-upd"); + expect(runs[0].status).toBe("success"); + expect(runs[0].endtime).toBe(99999); + }); + + it("updateRun 找不到 id 时静默忽略", async () => { + await repo.appendRun(makeRun({ id: "r-exists", taskId: "t-miss" })); + await repo.updateRun("t-miss", "r-nonexistent", { status: "error" }); + const runs = await repo.listRuns("t-miss"); + expect(runs).toHaveLength(1); + expect(runs[0].status).toBe("running"); + }); + + it("appendRun 超过 MAX_RUNS_PER_TASK 时裁剪最老记录", async () => { + const taskId = "task-ring"; + for (let i = 0; i < 105; i++) { + await repo.appendRun(makeRun({ id: `rr-${i}`, taskId, starttime: i })); + } + const runs = await repo.listRuns(taskId, 200); + expect(runs.length).toBe(100); + // 最新的在前,最老 5 条被裁剪掉(rr-0 ~ rr-4) + expect(runs[0].id).toBe("rr-104"); + expect(runs[99].id).toBe("rr-5"); + }); +}); diff --git a/src/app/repo/agent_task.ts b/src/app/repo/agent_task.ts new file mode 100644 index 000000000..a66acb863 --- /dev/null +++ b/src/app/repo/agent_task.ts @@ -0,0 +1,68 @@ +import type { AgentTask, AgentTaskRun } from "@App/app/service/agent/core/types"; +import { Repo } from "./repo"; +import { OPFSRepo } from "./opfs_repo"; + +export class AgentTaskRepo extends Repo { + constructor() { + super("agent_task:"); + this.enableCache(); + } + + async listTasks(): Promise { + return this.find(); + } + + async getTask(id: string): Promise { + return this.get(id); + } + + async saveTask(task: AgentTask): Promise { + await this._save(task.id, task); + } + + async removeTask(id: string): Promise { + await this.delete(id); + // 同时清理关联的 runs + const runRepo = new AgentTaskRunRepo(); + await runRepo.clearRuns(id); + } +} + +const MAX_RUNS_PER_TASK = 100; + +export class AgentTaskRunRepo extends OPFSRepo { + constructor() { + super("task_runs"); + } + + private filename(taskId: string): string { + return `${taskId}.json`; + } + + async appendRun(run: AgentTaskRun): Promise { + const runs = await this.readJsonFile(this.filename(run.taskId), []); + runs.unshift(run); + // 环形缓冲:超过上限时裁剪最老的记录 + if (runs.length > MAX_RUNS_PER_TASK) { + runs.length = MAX_RUNS_PER_TASK; + } + await this.writeJsonFile(this.filename(run.taskId), runs); + } + + async updateRun(taskId: string, id: string, data: Partial): Promise { + const runs = await this.readJsonFile(this.filename(taskId), []); + const idx = runs.findIndex((r) => r.id === id); + if (idx < 0) return; + Object.assign(runs[idx], data); + await this.writeJsonFile(this.filename(taskId), runs); + } + + async listRuns(taskId: string, limit = 50): Promise { + const runs = await this.readJsonFile(this.filename(taskId), []); + return runs.slice(0, limit); + } + + async clearRuns(taskId: string): Promise { + await this.deleteFile(this.filename(taskId)); + } +} diff --git a/src/app/repo/mcp_server_repo.ts b/src/app/repo/mcp_server_repo.ts new file mode 100644 index 000000000..587c0c82d --- /dev/null +++ b/src/app/repo/mcp_server_repo.ts @@ -0,0 +1,26 @@ +import type { MCPServerConfig } from "@App/app/service/agent/core/types"; +import { Repo } from "./repo"; + +// 使用 chrome.storage.local 存储 MCP 服务器配置 +export class MCPServerRepo extends Repo { + constructor() { + super("mcp_server:"); + this.enableCache(); + } + + async listServers(): Promise { + return this.find(); + } + + async getServer(id: string): Promise { + return this.get(id); + } + + async saveServer(config: MCPServerConfig): Promise { + await this._save(config.id, config); + } + + async removeServer(id: string): Promise { + await this.delete(id); + } +} diff --git a/src/app/repo/opfs_repo.ts b/src/app/repo/opfs_repo.ts new file mode 100644 index 000000000..626059777 --- /dev/null +++ b/src/app/repo/opfs_repo.ts @@ -0,0 +1,99 @@ +// OPFS(Origin Private File System)通用 Repo 基类 +// 所有 Agent 相关的持久化数据统一存储在 agents/ 目录下 + +const AGENTS_ROOT = "agents"; + +// 获取 agents 根目录 +async function getAgentsRoot(): Promise { + const root = await navigator.storage.getDirectory(); + return root.getDirectoryHandle(AGENTS_ROOT, { create: true }); +} + +// 按路径逐级获取子目录,自动创建不存在的目录 +async function getSubDir(base: FileSystemDirectoryHandle, path: string): Promise { + const parts = path.split("/").filter(Boolean); + let dir = base; + for (const part of parts) { + dir = await dir.getDirectoryHandle(part, { create: true }); + } + return dir; +} + +/** + * OPFS Repo 基类,提供基于 OPFS 的 JSON 文件读写能力 + * + * 目录结构示例: + * agents/conversations/ - 对话元数据 + * agents/conversations/messages/ - 对话消息 + * agents/skills/ - Skill 配置 + * agents/memory/ - Agent 记忆 + */ +export class OPFSRepo { + constructor(private subPath: string) {} + + // 获取当前 Repo 对应的目录 + protected async getDir(): Promise { + const root = await getAgentsRoot(); + return getSubDir(root, this.subPath); + } + + // 获取子目录 + protected async getChildDir(childPath: string): Promise { + const dir = await this.getDir(); + return getSubDir(dir, childPath); + } + + // 读取 JSON 文件,文件不存在时返回默认值 + protected async readJsonFile(filename: string, defaultValue: T, dir?: FileSystemDirectoryHandle): Promise { + try { + const targetDir = dir || (await this.getDir()); + const fileHandle = await targetDir.getFileHandle(filename); + const file = await fileHandle.getFile(); + const text = await file.text(); + return JSON.parse(text) as T; + } catch { + return defaultValue; + } + } + + // 写入 JSON 文件 + protected async writeJsonFile(filename: string, data: unknown, dir?: FileSystemDirectoryHandle): Promise { + const targetDir = dir || (await this.getDir()); + const fileHandle = await targetDir.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(JSON.stringify(data)); + await writable.close(); + } + + // 删除文件,不存在时忽略 + protected async deleteFile(filename: string, dir?: FileSystemDirectoryHandle): Promise { + try { + const targetDir = dir || (await this.getDir()); + await targetDir.removeEntry(filename); + } catch { + // 文件不存在则忽略 + } + } + + // 递归删除子目录 + protected async removeDirectory(name: string, dir?: FileSystemDirectoryHandle): Promise { + try { + const targetDir = dir || (await this.getDir()); + await targetDir.removeEntry(name, { recursive: true }); + } catch { + // 目录不存在则忽略 + } + } + + // 列出目录下所有文件名 + protected async listFiles(dir?: FileSystemDirectoryHandle): Promise { + const targetDir = dir || (await this.getDir()); + const files: string[] = []; + for await (const [name, handle] of targetDir as any) { + if (handle.kind === "file") { + files.push(name); + } + } + return files; + } +} diff --git a/src/app/repo/skill_repo.ts b/src/app/repo/skill_repo.ts new file mode 100644 index 000000000..7973624d3 --- /dev/null +++ b/src/app/repo/skill_repo.ts @@ -0,0 +1,183 @@ +import type { SkillScriptRecord, SkillRecord, SkillReference, SkillSummary } from "@App/app/service/agent/core/types"; +import { OPFSRepo } from "./opfs_repo"; + +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[] +// agents/skills/data/{sanitized_name}/ +// skill.json — SkillRecord +// scripts/{toolname}.json — SkillScriptRecord +// references/{name}.json — { name, content } +export class SkillRepo extends OPFSRepo { + constructor() { + super("skills"); + } + + // 将名称转为安全的目录名,过滤路径分隔符和特殊字符 + static sanitizeName(name: string): string { + return name.replace(/[/\\:*?"<>|.]/g, "_"); + } + + private async getDataDir(): Promise { + return this.getChildDir(DATA_DIR); + } + + private async getSkillDir(name: string): Promise { + return this.getChildDir(`${DATA_DIR}/${SkillRepo.sanitizeName(name)}`); + } + + private async readRegistry(): Promise { + return this.readJsonFile(REGISTRY_FILE, []); + } + + private async writeRegistry(summaries: SkillSummary[]): Promise { + await this.writeJsonFile(REGISTRY_FILE, summaries); + } + + async listSkills(): Promise { + return this.readRegistry(); + } + + async getSkill(name: string): Promise { + const registry = await this.readRegistry(); + if (!registry.find((s) => s.name === name)) return null; + const skillDir = await this.getSkillDir(name); + return this.readJsonFile("skill.json", null, skillDir); + } + + async saveSkill(record: SkillRecord, scripts?: SkillScriptRecord[], references?: SkillReference[]): Promise { + const skillDir = await this.getSkillDir(record.name); + + // 写 skill.json + await this.writeJsonFile("skill.json", record, skillDir); + + // 写 scripts(先清空旧文件再写入,防止更新后残留旧工具) + if (scripts) { + const sanitized = SkillRepo.sanitizeName(record.name); + await this.removeDirectory(SCRIPTS_DIR, await this.getSkillDir(record.name)); + if (scripts.length > 0) { + const scriptsDir = await this.getChildDir(`${DATA_DIR}/${sanitized}/${SCRIPTS_DIR}`); + for (const script of scripts) { + await this.writeJsonFile(`${script.name}.json`, script, scriptsDir); + } + } + } + + // 写 references(先清空旧文件再写入) + if (references) { + const sanitized = SkillRepo.sanitizeName(record.name); + await this.removeDirectory(REFERENCES_DIR, await this.getSkillDir(record.name)); + if (references.length > 0) { + const refsDir = await this.getChildDir(`${DATA_DIR}/${sanitized}/${REFERENCES_DIR}`); + for (const ref of references) { + await this.writeJsonFile(`${ref.name}.json`, ref, refsDir); + } + } + } + + // 更新 registry + const registry = await this.readRegistry(); + const idx = registry.findIndex((s) => s.name === record.name); + const summary: SkillSummary = { + name: record.name, + description: record.description, + toolNames: record.toolNames, + referenceNames: record.referenceNames, + ...(record.config && Object.keys(record.config).length > 0 ? { hasConfig: true } : {}), + // 保留已有的 enabled 状态 + ...(idx >= 0 && registry[idx].enabled !== undefined ? { enabled: registry[idx].enabled } : {}), + installtime: record.installtime, + updatetime: record.updatetime, + }; + if (idx >= 0) { + registry[idx] = summary; + } else { + registry.push(summary); + } + await this.writeRegistry(registry); + } + + async removeSkill(name: string): Promise { + const registry = await this.readRegistry(); + if (!registry.find((s) => s.name === name)) return false; + + // 先更新 registry + const filtered = registry.filter((s) => s.name !== name); + await this.writeRegistry(filtered); + + // 再删除整个 data/{sanitized_name}/ 目录 + const dataDir = await this.getDataDir(); + await this.removeDirectory(SkillRepo.sanitizeName(name), dataDir); + + return true; + } + + async getSkillScripts(name: string): Promise { + try { + const scriptsDir = await this.getChildDir(`${DATA_DIR}/${SkillRepo.sanitizeName(name)}/${SCRIPTS_DIR}`); + const files = await this.listFiles(scriptsDir); + const records: SkillScriptRecord[] = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + const record = await this.readJsonFile(file, null, scriptsDir); + if (record) records.push(record); + } + return records; + } catch { + return []; + } + } + + async getSkillReferences(name: string): Promise { + try { + const refsDir = await this.getChildDir(`${DATA_DIR}/${SkillRepo.sanitizeName(name)}/${REFERENCES_DIR}`); + const files = await this.listFiles(refsDir); + const refs: SkillReference[] = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + const ref = await this.readJsonFile(file, null, refsDir); + if (ref) refs.push(ref); + } + return refs; + } catch { + return []; + } + } + + async getReference(skillName: string, refName: string): Promise { + try { + const refsDir = await this.getChildDir(`${DATA_DIR}/${SkillRepo.sanitizeName(skillName)}/${REFERENCES_DIR}`); + return this.readJsonFile(`${refName}.json`, null, refsDir); + } catch { + return null; + } + } + + async setSkillEnabled(name: string, enabled: boolean): Promise { + const registry = await this.readRegistry(); + const idx = registry.findIndex((s) => s.name === name); + if (idx < 0) return false; + registry[idx].enabled = enabled; + await this.writeRegistry(registry); + return true; + } + + 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/core/agent.test.ts b/src/app/service/agent/core/agent.test.ts new file mode 100644 index 000000000..900a27bf7 --- /dev/null +++ b/src/app/service/agent/core/agent.test.ts @@ -0,0 +1,1506 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { SSEParser } from "./sse_parser"; +import { buildOpenAIRequest, parseOpenAIStream } from "./providers/openai"; +import { buildAnthropicRequest, parseAnthropicStream } from "./providers/anthropic"; +import type { AgentModelConfig } from "./types"; +import type { ChatRequest, ChatStreamEvent, ToolDefinition } from "./types"; +import { AgentService } from "@App/app/service/agent/service_worker/agent"; +import type { ToolRegistry } from "./tool_registry"; +import type { ToolExecutor } from "./tool_registry"; + +// mock agent_chat repo 单例:子服务通过 import { agentChatRepo } 直接使用该 mock 对象 +const { mockChatRepo } = vi.hoisted(() => ({ + mockChatRepo: {} as any, +})); + +vi.mock("@App/app/repo/agent_chat", () => ({ + AgentChatRepo: class {}, + agentChatRepo: mockChatRepo, +})); + +// 模型配置 +const openaiConfig: AgentModelConfig = { + id: "test-openai", + name: "Test OpenAI", + provider: "openai", + apiBaseUrl: "https://api.openai.com/v1", + apiKey: "sk-test", + model: "gpt-4o", +}; + +const anthropicConfig: AgentModelConfig = { + id: "test-anthropic", + name: "Test Anthropic", + provider: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + apiKey: "sk-ant-test", + model: "claude-sonnet-4-20250514", +}; + +const testTools: ToolDefinition[] = [ + { + name: "get_weather", + description: "获取指定城市的天气", + parameters: { + type: "object", + properties: { + city: { type: "string", description: "城市名称" }, + }, + required: ["city"], + }, + }, +]; + +// 辅助函数:将 SSE 文本构造为 ReadableStreamDefaultReader +function createMockReader(chunks: string[]): ReadableStreamDefaultReader { + const encoder = new TextEncoder(); + let index = 0; + return { + read: async () => { + if (index >= chunks.length) { + return { done: true, value: undefined } as ReadableStreamReadDoneResult; + } + const value = encoder.encode(chunks[index++]); + return { done: false, value } as ReadableStreamReadResult; + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + } as ReadableStreamDefaultReader; +} + +// 辅助函数:收集 parseStream 产生的所有事件 +async function collectEvents( + parseFn: typeof parseOpenAIStream, + chunks: string[], + signal?: AbortSignal +): Promise { + const events: ChatStreamEvent[] = []; + const reader = createMockReader(chunks); + await parseFn(reader, (e) => events.push(e), signal ?? new AbortController().signal); + return events; +} + +describe("SSEParser", () => { + it("应正确解析单个 SSE 事件", () => { + const parser = new SSEParser(); + const events = parser.parse('data: {"text":"hello"}\n\n'); + expect(events).toHaveLength(1); + expect(events[0].event).toBe("message"); + expect(events[0].data).toBe('{"text":"hello"}'); + }); + + it("应正确解析带 event 字段的事件", () => { + const parser = new SSEParser(); + const events = parser.parse('event: content_block_delta\ndata: {"delta":"hi"}\n\n'); + expect(events).toHaveLength(1); + expect(events[0].event).toBe("content_block_delta"); + }); + + it("应正确处理跨 chunk 的事件", () => { + const parser = new SSEParser(); + const events1 = parser.parse('data: {"text":'); + expect(events1).toHaveLength(0); + const events2 = parser.parse('"hello"}\n\n'); + expect(events2).toHaveLength(1); + expect(events2[0].data).toBe('{"text":"hello"}'); + }); + + it("应正确解析多个连续事件", () => { + const parser = new SSEParser(); + const events = parser.parse('data: {"a":1}\n\ndata: {"b":2}\n\n'); + expect(events).toHaveLength(2); + }); + + it("应忽略注释行", () => { + const parser = new SSEParser(); + const events = parser.parse(": comment\ndata: test\n\n"); + expect(events).toHaveLength(1); + expect(events[0].data).toBe("test"); + }); +}); + +describe("OpenAI Provider", () => { + describe("buildOpenAIRequest", () => { + it("应正确构造基础请求", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test-openai", + messages: [ + { role: "system", content: "你是助手" }, + { role: "user", content: "你好" }, + ], + }; + + const { url, init } = buildOpenAIRequest(openaiConfig, request); + expect(url).toBe("https://api.openai.com/v1/chat/completions"); + expect(init.method).toBe("POST"); + + const headers = init.headers as Record; + expect(headers["Authorization"]).toBe("Bearer sk-test"); + + const body = JSON.parse(init.body as string); + expect(body.model).toBe("gpt-4o"); + expect(body.messages).toHaveLength(2); + expect(body.stream).toBe(true); + expect(body.tools).toBeUndefined(); + }); + + it("应正确包含工具定义", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test-openai", + messages: [{ role: "user", content: "北京天气" }], + tools: testTools, + }; + + const { init } = buildOpenAIRequest(openaiConfig, request); + const body = JSON.parse(init.body as string); + + expect(body.tools).toHaveLength(1); + expect(body.tools[0].type).toBe("function"); + expect(body.tools[0].function.name).toBe("get_weather"); + expect(body.tools[0].function.description).toBe("获取指定城市的天气"); + expect(body.tools[0].function.parameters.properties.city.type).toBe("string"); + }); + + it("应正确处理 tool 角色消息", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test-openai", + messages: [ + { role: "user", content: "北京天气" }, + { role: "assistant", content: "" }, + { role: "tool", content: '{"temp": 25}', toolCallId: "call_123" }, + ], + tools: testTools, + }; + + const { init } = buildOpenAIRequest(openaiConfig, request); + const body = JSON.parse(init.body as string); + + expect(body.messages[2].role).toBe("tool"); + expect(body.messages[2].tool_call_id).toBe("call_123"); + }); + + it("应正确转换 assistant 消息中的 toolCalls 为 OpenAI tool_calls 格式", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test-openai", + messages: [ + { role: "user", content: "北京天气" }, + { + role: "assistant", + content: "", + toolCalls: [{ id: "call_abc", name: "get_weather", arguments: '{"city":"北京"}' }], + }, + { role: "tool", content: '{"temp": 25}', toolCallId: "call_abc" }, + ], + tools: testTools, + }; + + const { init } = buildOpenAIRequest(openaiConfig, request); + const body = JSON.parse(init.body as string); + + // assistant 消息应包含 tool_calls + const assistantMsg = body.messages[1]; + expect(assistantMsg.role).toBe("assistant"); + expect(assistantMsg.tool_calls).toHaveLength(1); + expect(assistantMsg.tool_calls[0].id).toBe("call_abc"); + expect(assistantMsg.tool_calls[0].type).toBe("function"); + expect(assistantMsg.tool_calls[0].function.name).toBe("get_weather"); + expect(assistantMsg.tool_calls[0].function.arguments).toBe('{"city":"北京"}'); + + // tool 消息应有 tool_call_id + const toolMsg = body.messages[2]; + expect(toolMsg.role).toBe("tool"); + expect(toolMsg.tool_call_id).toBe("call_abc"); + }); + + it("assistant 消息没有 toolCalls 时不应生成 tool_calls 字段", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test-openai", + messages: [ + { role: "user", content: "你好" }, + { role: "assistant", content: "你好!" }, + ], + }; + + const { init } = buildOpenAIRequest(openaiConfig, request); + const body = JSON.parse(init.body as string); + + expect(body.messages[1].tool_calls).toBeUndefined(); + }); + }); + + describe("parseOpenAIStream", () => { + it("应正确解析内容增量事件", async () => { + const events = await collectEvents(parseOpenAIStream, [ + 'data: {"choices":[{"delta":{"content":"你好"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"世界"}}]}\n\n', + "data: [DONE]\n\n", + ]); + + expect(events).toHaveLength(3); + expect(events[0]).toEqual({ type: "content_delta", delta: "你好" }); + expect(events[1]).toEqual({ type: "content_delta", delta: "世界" }); + expect(events[2]).toEqual({ type: "done" }); + }); + + it("应正确解析 tool_call 事件", async () => { + const events = await collectEvents(parseOpenAIStream, [ + 'data: {"choices":[{"delta":{"tool_calls":[{"id":"call_1","function":{"name":"get_weather","arguments":""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"{\\"city\\":"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"\\"北京\\"}"}}]}}]}\n\n', + "data: [DONE]\n\n", + ]); + + expect(events[0].type).toBe("tool_call_start"); + if (events[0].type === "tool_call_start") { + expect(events[0].toolCall.id).toBe("call_1"); + expect(events[0].toolCall.name).toBe("get_weather"); + } + expect(events[1].type).toBe("tool_call_delta"); + expect(events[2].type).toBe("tool_call_delta"); + expect(events[3]).toEqual({ type: "done" }); + }); + + it("应正确解析带 usage 的 done 事件", async () => { + const events = await collectEvents(parseOpenAIStream, [ + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', + 'data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n', + ]); + + expect(events).toHaveLength(2); + expect(events[1]).toEqual({ + type: "done", + usage: { inputTokens: 10, outputTokens: 5 }, + }); + }); + + it("应正确处理 SSE 流中的 API 错误响应", async () => { + // 模拟真实场景:API 返回 200 但 SSE 数据中包含错误 + const events = await collectEvents(parseOpenAIStream, [ + 'data: {"error":{"message":"Request failed with status code 400","type":"invalid_request_error","code":null}}\n\n', + 'data: {"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[],"usage":{"prompt_tokens":100,"completion_tokens":0,"total_tokens":100}}\n\n', + "data: [DONE]\n\n", + ]); + + // 应只有一个 error 事件,后续数据不再处理 + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + if (events[0].type === "error") { + expect(events[0].message).toBe("Request failed with status code 400"); + } + }); + + it("应处理没有 message 字段的错误响应", async () => { + const events = await collectEvents(parseOpenAIStream, [ + 'data: {"error":{"type":"server_error","code":"internal"}}\n\n', + ]); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + if (events[0].type === "error") { + // 没有 message 时应回退到 JSON.stringify + expect(events[0].message).toContain("server_error"); + } + }); + + it("应正确处理读取流异常", async () => { + const reader = { + read: async () => { + throw new Error("Network error"); + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + } as unknown as ReadableStreamDefaultReader; + + const events: ChatStreamEvent[] = []; + await parseOpenAIStream(reader, (e) => events.push(e), new AbortController().signal); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + if (events[0].type === "error") { + expect(events[0].message).toBe("Network error"); + } + }); + + it("signal 中断时不应产生错误事件", async () => { + const abortController = new AbortController(); + abortController.abort(); + + const events = await collectEvents( + parseOpenAIStream, + ['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n'], + abortController.signal + ); + + // signal 已中断,不应处理任何数据 + expect(events).toHaveLength(0); + }); + }); +}); + +describe("Anthropic Provider", () => { + describe("buildAnthropicRequest", () => { + it("应正确构造基础请求", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test-anthropic", + messages: [ + { role: "system", content: "你是助手" }, + { role: "user", content: "你好" }, + ], + }; + + const { url, init } = buildAnthropicRequest(anthropicConfig, request); + expect(url).toBe("https://api.anthropic.com/v1/messages"); + + const headers = init.headers as Record; + expect(headers["x-api-key"]).toBe("sk-ant-test"); + + const body = JSON.parse(init.body as string); + expect(body.model).toBe("claude-sonnet-4-20250514"); + expect(body.system).toEqual([{ type: "text", text: "你是助手", cache_control: { type: "ephemeral" } }]); + // system 消息不应出现在 messages 中 + expect(body.messages).toHaveLength(1); + expect(body.messages[0].role).toBe("user"); + expect(body.tools).toBeUndefined(); + }); + + it("应正确包含工具定义(Anthropic 格式)", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test-anthropic", + messages: [{ role: "user", content: "北京天气" }], + tools: testTools, + }; + + const { init } = buildAnthropicRequest(anthropicConfig, request); + const body = JSON.parse(init.body as string); + + expect(body.tools).toHaveLength(1); + expect(body.tools[0].name).toBe("get_weather"); + expect(body.tools[0].input_schema).toBeDefined(); + // Anthropic 不用 function 包裹 + expect(body.tools[0].type).toBeUndefined(); + }); + + it("应正确转换 tool 角色消息为 Anthropic tool_result 格式", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test-anthropic", + messages: [ + { role: "user", content: "北京天气" }, + { role: "assistant", content: "" }, + { role: "tool", content: '{"temp": 25}', toolCallId: "toolu_123" }, + ], + tools: testTools, + }; + + const { init } = buildAnthropicRequest(anthropicConfig, request); + const body = JSON.parse(init.body as string); + + // tool 消息应转换为 user 角色 + tool_result content block + const toolMsg = body.messages[2]; + expect(toolMsg.role).toBe("user"); + expect(toolMsg.content).toBeInstanceOf(Array); + expect(toolMsg.content[0].type).toBe("tool_result"); + expect(toolMsg.content[0].tool_use_id).toBe("toolu_123"); + expect(toolMsg.content[0].content).toBe('{"temp": 25}'); + }); + + it("应正确转换 assistant 消息中的 toolCalls 为 Anthropic content blocks", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test-anthropic", + messages: [ + { role: "user", content: "北京天气" }, + { + role: "assistant", + content: "让我查一下", + toolCalls: [{ id: "toolu_abc", name: "get_weather", arguments: '{"city":"北京"}' }], + }, + { role: "tool", content: '{"temp": 25}', toolCallId: "toolu_abc" }, + ], + tools: testTools, + }; + + const { init } = buildAnthropicRequest(anthropicConfig, request); + const body = JSON.parse(init.body as string); + + // assistant 消息应转换为 content blocks(text + tool_use) + const assistantMsg = body.messages[1]; + expect(assistantMsg.role).toBe("assistant"); + expect(assistantMsg.content).toBeInstanceOf(Array); + expect(assistantMsg.content).toHaveLength(2); + expect(assistantMsg.content[0]).toEqual({ type: "text", text: "让我查一下" }); + expect(assistantMsg.content[1]).toEqual({ + type: "tool_use", + id: "toolu_abc", + name: "get_weather", + input: { city: "北京" }, + }); + }); + }); + + describe("parseAnthropicStream", () => { + it("应正确解析内容增量事件", async () => { + const events = await collectEvents(parseAnthropicStream, [ + 'event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"你好"}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"世界"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + expect(events).toHaveLength(3); + expect(events[0]).toEqual({ type: "content_delta", delta: "你好" }); + expect(events[1]).toEqual({ type: "content_delta", delta: "世界" }); + expect(events[2]).toEqual({ type: "done" }); + }); + + it("应正确解析 tool_use 事件", async () => { + const events = await collectEvents(parseAnthropicStream, [ + 'event: content_block_start\ndata: {"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather"}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"input_json_delta","partial_json":"{\\"city\\":"}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"input_json_delta","partial_json":"\\"北京\\"}"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + expect(events[0].type).toBe("tool_call_start"); + if (events[0].type === "tool_call_start") { + expect(events[0].toolCall.id).toBe("toolu_1"); + expect(events[0].toolCall.name).toBe("get_weather"); + } + expect(events[1].type).toBe("tool_call_delta"); + expect(events[2].type).toBe("tool_call_delta"); + expect(events[3]).toEqual({ type: "done" }); + }); + + it("应正确解析带 usage 的 message_delta 事件", async () => { + const events = await collectEvents(parseAnthropicStream, [ + 'event: message_delta\ndata: {"usage":{"input_tokens":20,"output_tokens":8}}\n\n', + ]); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + type: "done", + usage: { inputTokens: 20, outputTokens: 8 }, + }); + }); + + it("应正确处理 error 事件", async () => { + const events = await collectEvents(parseAnthropicStream, [ + 'event: error\ndata: {"error":{"message":"Rate limit exceeded"}}\n\n', + ]); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + if (events[0].type === "error") { + expect(events[0].message).toBe("Rate limit exceeded"); + } + }); + + it("应正确解析 thinking 事件", async () => { + const events = await collectEvents(parseAnthropicStream, [ + 'event: content_block_start\ndata: {"content_block":{"type":"thinking"}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"thinking_delta","thinking":"让我想想..."}}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + expect(events).toHaveLength(2); // thinking_delta + done(thinking start 不产生事件) + expect(events[0]).toEqual({ type: "thinking_delta", delta: "让我想想..." }); + expect(events[1]).toEqual({ type: "done" }); + }); + }); +}); + +describe("Agent Types", () => { + it("ChatRequest 应支持 tools 字段", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test", + messages: [{ role: "user", content: "hello" }], + tools: testTools, + }; + expect(request.tools).toHaveLength(1); + }); + + it("ChatRequest 消息应支持 tool 角色和 toolCallId", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test", + messages: [ + { role: "user", content: "hello" }, + { role: "tool", content: "result", toolCallId: "call_1" }, + ], + }; + expect(request.messages[1].role).toBe("tool"); + expect(request.messages[1].toolCallId).toBe("call_1"); + }); + + it("ChatRequest 消息应支持 toolCalls 字段", () => { + const request: ChatRequest = { + conversationId: "test", + modelId: "test", + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: "", + toolCalls: [{ id: "call_1", name: "test_tool", arguments: "{}" }], + }, + { role: "tool", content: "result", toolCallId: "call_1" }, + ], + }; + expect(request.messages[1].toolCalls).toHaveLength(1); + expect(request.messages[1].toolCalls![0].name).toBe("test_tool"); + }); +}); + +// ---- callLLMWithToolLoop 测试 ---- + +// 辅助:构造 mock Response,兼容 jsdom 不支持 ReadableStream body 的情况 +function buildSSEResponse(sseChunks: string[]): Response { + const encoder = new TextEncoder(); + let index = 0; + const mockReader = { + read: async () => { + if (index >= sseChunks.length) { + return { done: true, value: undefined } as ReadableStreamReadDoneResult; + } + const value = encoder.encode(sseChunks[index++]); + return { done: false, value } as ReadableStreamReadResult; + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + }; + return { + ok: true, + status: 200, + body: { + getReader: () => mockReader, + }, + text: async () => "", + } as unknown as Response; +} + +// 辅助:构造纯文本 SSE 数据(OpenAI 格式) +function makeTextSSE(text: string, usage?: { prompt_tokens: number; completion_tokens: number }): string[] { + const chunks: string[] = []; + chunks.push(`data: {"choices":[{"delta":{"content":"${text}"}}]}\n\n`); + if (usage) { + chunks.push(`data: {"usage":${JSON.stringify(usage)}}\n\n`); + } else { + chunks.push("data: [DONE]\n\n"); + } + return chunks; +} + +// 辅助:构造带 tool_call 的 SSE 数据(OpenAI 格式) +function makeToolCallSSE( + toolId: string, + toolName: string, + args: string, + usage?: { prompt_tokens: number; completion_tokens: number } +): string[] { + const chunks: string[] = []; + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"id":"${toolId}","function":{"name":"${toolName}","arguments":""}}]}}]}\n\n` + ); + chunks.push( + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"${args.replace(/"/g, '\\"')}"}}]}}]}\n\n` + ); + if (usage) { + chunks.push(`data: {"usage":${JSON.stringify(usage)}}\n\n`); + } else { + chunks.push("data: [DONE]\n\n"); + } + return chunks; +} + +// 创建 mock AgentService 实例 +function createTestService() { + // 重置 agent_chat 单例 mock 方法(保持对象身份不变,只替换 vi.fn) + Object.assign(mockChatRepo, { + appendMessage: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockResolvedValue([]), + listConversations: vi.fn().mockResolvedValue([]), + saveConversation: vi.fn().mockResolvedValue(undefined), + saveMessages: vi.fn().mockResolvedValue(undefined), + getAttachment: vi.fn().mockResolvedValue(null), + saveAttachment: vi.fn().mockResolvedValue(0), + }); + + const mockGroup = { + on: vi.fn(), + } as any; + + const mockSender = {} as any; + + const service = new AgentService(mockGroup, mockSender); + + // 替换 modelRepo(避免 chrome.storage 调用) + (service as any).modelRepo = { + listModels: vi.fn().mockResolvedValue([openaiConfig]), + getModel: vi.fn().mockImplementation((id: string) => { + if (id === "test-openai") return Promise.resolve(openaiConfig); + return Promise.resolve(undefined); + }), + getDefaultModelId: vi.fn().mockResolvedValue("test-openai"), + }; + + const toolRegistry = (service as any).toolRegistry as ToolRegistry; + + return { service, mockRepo: mockChatRepo, toolRegistry }; +} + +describe("callLLMWithToolLoop", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("无 tool calling 的简单对话", async () => { + const { service } = createTestService(); + const events: ChatStreamEvent[] = []; + + fetchSpy.mockResolvedValueOnce( + buildSSEResponse(makeTextSSE("你好世界", { prompt_tokens: 10, completion_tokens: 5 })) + ); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "你好" }], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + }); + + // 应该收到 content_delta 和 done + const contentEvents = events.filter((e) => e.type === "content_delta"); + expect(contentEvents.length).toBeGreaterThanOrEqual(1); + + const doneEvent = events.find((e) => e.type === "done"); + expect(doneEvent).toBeDefined(); + expect(doneEvent!.type === "done" && doneEvent!.usage).toEqual({ + inputTokens: 10, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }); + }); + + it("单轮 tool calling", async () => { + const { service, toolRegistry } = createTestService(); + const events: ChatStreamEvent[] = []; + + // 注册一个 mock 工具 + const mockExecutor: ToolExecutor = { + execute: vi.fn().mockResolvedValue({ temp: 25 }), + }; + toolRegistry.registerBuiltin( + { name: "get_weather", description: "获取天气", parameters: { type: "object", properties: {} } }, + mockExecutor + ); + + // 第一次调用:LLM 返回 tool_call + fetchSpy.mockResolvedValueOnce( + buildSSEResponse( + makeToolCallSSE("call_1", "get_weather", '{"city":"北京"}', { prompt_tokens: 20, completion_tokens: 10 }) + ) + ); + // 第二次调用:LLM 返回纯文本 + fetchSpy.mockResolvedValueOnce( + buildSSEResponse(makeTextSSE("北京今天25度", { prompt_tokens: 30, completion_tokens: 8 })) + ); + + const messages: ChatRequest["messages"] = [{ role: "user", content: "北京天气怎么样" }]; + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages, + tools: [{ name: "get_weather", description: "获取天气", parameters: {} }], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + }); + + // fetch 应被调用两次 + expect(fetchSpy).toHaveBeenCalledTimes(2); + + // 工具应被执行 + expect(mockExecutor.execute).toHaveBeenCalledWith({ city: "北京" }); + + // messages 应包含 assistant(tool_call) + tool(result) + user 原始消息 + expect(messages.length).toBe(3); // user + assistant(toolCalls) + tool(result) + expect(messages[1].role).toBe("assistant"); + expect(messages[1].toolCalls).toBeDefined(); + expect(messages[2].role).toBe("tool"); + + // 应有 done 事件,usage 是累加的 + const doneEvent = events.find((e) => e.type === "done"); + expect(doneEvent).toBeDefined(); + if (doneEvent?.type === "done") { + expect(doneEvent.usage).toEqual({ + inputTokens: 50, + outputTokens: 18, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }); + } + }); + + it("多轮 tool calling", async () => { + const { service, toolRegistry } = createTestService(); + const events: ChatStreamEvent[] = []; + + const mockExecutor: ToolExecutor = { + execute: vi.fn().mockResolvedValue("result"), + }; + toolRegistry.registerBuiltin( + { name: "search", description: "搜索", parameters: { type: "object", properties: {} } }, + mockExecutor + ); + + // 第一次:tool_call + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeToolCallSSE("call_1", "search", '{"q":"a"}'))); + // 第二次:又一个 tool_call + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeToolCallSSE("call_2", "search", '{"q":"b"}'))); + // 第三次:纯文本 + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("完成"))); + + const messages: ChatRequest["messages"] = [{ role: "user", content: "搜索" }]; + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages, + tools: [{ name: "search", description: "搜索", parameters: {} }], + maxIterations: 10, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(mockExecutor.execute).toHaveBeenCalledTimes(2); + // user + assistant+tool + assistant+tool = 5 条消息 + expect(messages.length).toBe(5); + expect(events.find((e) => e.type === "done")).toBeDefined(); + }); + + it("超过 maxIterations 限制", async () => { + const { service, toolRegistry } = createTestService(); + const events: ChatStreamEvent[] = []; + + toolRegistry.registerBuiltin( + { name: "loop_tool", description: "循环工具", parameters: { type: "object", properties: {} } }, + { execute: vi.fn().mockResolvedValue("ok") } + ); + + // 每次都返回 tool_call + fetchSpy.mockImplementation(() => Promise.resolve(buildSSEResponse(makeToolCallSSE("call_x", "loop_tool", "{}")))); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + tools: [{ name: "loop_tool", description: "循环工具", parameters: {} }], + maxIterations: 3, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + }); + + // fetch 应被调用 3 次(maxIterations) + expect(fetchSpy).toHaveBeenCalledTimes(3); + + // 应收到 error 事件 + const errorEvent = events.find((e) => e.type === "error"); + expect(errorEvent).toBeDefined(); + if (errorEvent?.type === "error") { + expect(errorEvent.message).toContain("maximum iterations"); + expect(errorEvent.message).toContain("3"); + } + }); + + it("signal 中止后应提前退出", async () => { + const { service } = createTestService(); + const events: ChatStreamEvent[] = []; + const abortController = new AbortController(); + + // 模拟:callLLM 正常返回结果,但在返回后 signal 已被 abort + // 通过 mock callLLM 直接实现,避免 parseStream 阻塞 + const callLLMSpy = vi.spyOn(service as any, "callLLM").mockImplementation(async () => { + abortController.abort(); + return { content: "hello", toolCalls: undefined, usage: { inputTokens: 5, outputTokens: 3 } }; + }); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: abortController.signal, + scriptToolCallback: null, + }); + + // abort 后不应有 done 事件(callLLMWithToolLoop 在 signal.aborted 时直接 return) + expect(events.find((e) => e.type === "done")).toBeUndefined(); + callLLMSpy.mockRestore(); + }); + + it("scriptToolCallback 转发未注册的工具", async () => { + const { service } = createTestService(); + const events: ChatStreamEvent[] = []; + + const scriptCallback = vi.fn().mockResolvedValue([{ id: "call_1", result: '{"data":"from_script"}' }]); + + // 第一次:tool_call(工具未在 registry 注册) + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeToolCallSSE("call_1", "script_tool", '{"input":"test"}'))); + // 第二次:纯文本 + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("done"))); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + tools: [{ name: "script_tool", description: "脚本工具", parameters: {} }], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: scriptCallback, + }); + + // scriptCallback 应被调用 + expect(scriptCallback).toHaveBeenCalledTimes(1); + expect(scriptCallback).toHaveBeenCalledWith([expect.objectContaining({ id: "call_1", name: "script_tool" })]); + + expect(events.find((e) => e.type === "done")).toBeDefined(); + }); + + it("有 conversationId 时应持久化消息", async () => { + const { service, mockRepo } = createTestService(); + const events: ChatStreamEvent[] = []; + + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("回答"))); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "问题" }], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + conversationId: "conv-123", + }); + + // 应调用 appendMessage 持久化 assistant 消息 + expect(mockRepo.appendMessage).toHaveBeenCalledTimes(1); + expect(mockRepo.appendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: "conv-123", + role: "assistant", + content: "回答", + }) + ); + }); + + it("无 conversationId 时不应调用持久化", async () => { + const { service, mockRepo } = createTestService(); + const events: ChatStreamEvent[] = []; + + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("回答"))); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "问题" }], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + // 无 conversationId + }); + + expect(mockRepo.appendMessage).not.toHaveBeenCalled(); + }); + + it("有 conversationId 的 tool calling 应持久化所有消息", async () => { + const { service, mockRepo, toolRegistry } = createTestService(); + const events: ChatStreamEvent[] = []; + + toolRegistry.registerBuiltin( + { name: "my_tool", description: "工具", parameters: { type: "object", properties: {} } }, + { execute: vi.fn().mockResolvedValue("tool_result") } + ); + + // 第一次:tool_call + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeToolCallSSE("call_1", "my_tool", "{}"))); + // 第二次:纯文本 + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("最终回答"))); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "问题" }], + tools: [{ name: "my_tool", description: "工具", parameters: {} }], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + conversationId: "conv-456", + }); + + // 应持久化 3 条消息:assistant(tool_call) + tool(result) + assistant(final) + expect(mockRepo.appendMessage).toHaveBeenCalledTimes(3); + + // 第一次:assistant with toolCalls + expect(mockRepo.appendMessage.mock.calls[0][0]).toMatchObject({ + conversationId: "conv-456", + role: "assistant", + toolCalls: expect.arrayContaining([expect.objectContaining({ id: "call_1", name: "my_tool" })]), + }); + + // 第二次:tool result + expect(mockRepo.appendMessage.mock.calls[1][0]).toMatchObject({ + conversationId: "conv-456", + role: "tool", + toolCallId: "call_1", + }); + + // 第三次:最终 assistant 回答 + expect(mockRepo.appendMessage.mock.calls[2][0]).toMatchObject({ + conversationId: "conv-456", + role: "assistant", + content: "最终回答", + }); + }); + + it("callLLM 抛出异常时应向上传播", async () => { + const { service } = createTestService(); + const events: ChatStreamEvent[] = []; + + // 直接 mock callLLM,避免内部重试延迟 + vi.spyOn(service as any, "callLLM").mockRejectedValue(new Error("Internal server error")); + + await expect( + (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + }) + ).rejects.toThrow("Internal server error"); + + // 不应有 done 或 error 事件(异常直接抛出,由上层 catch) + expect(events.find((e) => e.type === "done")).toBeUndefined(); + }); + + it("callLLM HTTP 错误 - 纯文本错误体", async () => { + const { service } = createTestService(); + + // 直接 mock callLLM,避免内部重试延迟;429 是可重试错误,withRetry 会重试 3 次 + vi.spyOn(service as any, "callLLM").mockRejectedValue(new Error("API error: 429 - Rate limit exceeded")); + + await expect( + (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + maxIterations: 5, + sendEvent: () => {}, + signal: new AbortController().signal, + scriptToolCallback: null, + delayFn: async () => {}, + }) + ).rejects.toThrow("API error: 429 - Rate limit exceeded"); + }); + + it("callLLM HTTP 错误 - 空错误体", async () => { + const { service } = createTestService(); + + // 直接 mock callLLM,避免内部重试延迟;502 是可重试错误,withRetry 会重试 3 次 + vi.spyOn(service as any, "callLLM").mockRejectedValue(new Error("API error: 502")); + + await expect( + (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + maxIterations: 5, + sendEvent: () => {}, + signal: new AbortController().signal, + scriptToolCallback: null, + delayFn: async () => {}, + }) + ).rejects.toThrow("API error: 502"); + }); + + it("LLM 返回 toolCalls 但没有工具定义时应正常结束", async () => { + const { service } = createTestService(); + const events: ChatStreamEvent[] = []; + + // LLM 幻觉返回了 tool_call,但调用时没传 tools 且 registry 也没有注册工具 + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeToolCallSSE("call_1", "phantom_tool", '{"x":1}'))); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + // 不传 tools,allToolDefs 为空 + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + }); + + // 应只调用 1 次 fetch(不进入 tool calling 循环) + expect(fetchSpy).toHaveBeenCalledTimes(1); + // 应正常结束,发送 done + const doneEvent = events.find((e) => e.type === "done"); + expect(doneEvent).toBeDefined(); + }); + + it("callLLM 内部不应将 done/error 事件转发给 sendEvent", async () => { + const { service } = createTestService(); + const events: ChatStreamEvent[] = []; + + // 使用带 usage 的响应(parseOpenAIStream 会产生 done 事件) + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("hello", { prompt_tokens: 5, completion_tokens: 3 }))); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + }); + + // callLLM 内部过滤了 parseStream 产生的 done 事件,只有 callLLMWithToolLoop 发送的 done + // 所以只应有 1 个 done 事件(来自 callLLMWithToolLoop 第 306 行) + const doneEvents = events.filter((e) => e.type === "done"); + expect(doneEvents).toHaveLength(1); + + // content_delta 应正常转发 + const contentEvents = events.filter((e) => e.type === "content_delta"); + expect(contentEvents.length).toBeGreaterThanOrEqual(1); + }); + + it("单次响应包含多个 tool_calls", async () => { + const { service, toolRegistry, mockRepo } = createTestService(); + const events: ChatStreamEvent[] = []; + + const executorA: ToolExecutor = { execute: vi.fn().mockResolvedValue("result_a") }; + const executorB: ToolExecutor = { execute: vi.fn().mockResolvedValue("result_b") }; + + toolRegistry.registerBuiltin( + { name: "tool_a", description: "工具A", parameters: { type: "object", properties: {} } }, + executorA + ); + toolRegistry.registerBuiltin( + { name: "tool_b", description: "工具B", parameters: { type: "object", properties: {} } }, + executorB + ); + + // 直接 mock callLLM 返回多个 toolCalls(绕过 callLLM 内部 currentToolCall 单变量的限制) + let callCount = 0; + const callLLMSpy = vi.spyOn(service as any, "callLLM").mockImplementation(async () => { + callCount++; + if (callCount === 1) { + return { + content: "", + toolCalls: [ + { id: "call_a", name: "tool_a", arguments: '{"x":1}' }, + { id: "call_b", name: "tool_b", arguments: '{"y":2}' }, + ], + usage: { inputTokens: 20, outputTokens: 10 }, + }; + } + return { content: "两个工具都执行完了", usage: { inputTokens: 30, outputTokens: 8 } }; + }); + + const messages: ChatRequest["messages"] = [{ role: "user", content: "同时用两个工具" }]; + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages, + tools: [ + { name: "tool_a", description: "工具A", parameters: {} }, + { name: "tool_b", description: "工具B", parameters: {} }, + ], + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + conversationId: "conv-multi", + }); + + // 两个工具都应被执行 + expect(executorA.execute).toHaveBeenCalledTimes(1); + expect(executorB.execute).toHaveBeenCalledTimes(1); + + // messages: user + assistant(2 toolCalls) + tool_a_result + tool_b_result = 4 + expect(messages.length).toBe(4); + expect(messages[1].role).toBe("assistant"); + expect(messages[1].toolCalls).toHaveLength(2); + expect(messages[2].role).toBe("tool"); + expect(messages[3].role).toBe("tool"); + + // 持久化:assistant(toolCalls) + 2个tool结果 + assistant(final) = 4 + expect(mockRepo.appendMessage).toHaveBeenCalledTimes(4); + expect(mockRepo.appendMessage.mock.calls[1][0]).toMatchObject({ role: "tool", toolCallId: "call_a" }); + expect(mockRepo.appendMessage.mock.calls[2][0]).toMatchObject({ role: "tool", toolCallId: "call_b" }); + + expect(events.find((e) => e.type === "done")).toBeDefined(); + callLLMSpy.mockRestore(); + }); + + it("skipBuiltinTools 时仅使用传入的 tools", async () => { + const { service, toolRegistry } = createTestService(); + const events: ChatStreamEvent[] = []; + + // 注册一个内置工具 + const builtinExecutor: ToolExecutor = { + execute: vi.fn().mockResolvedValue("builtin_result"), + }; + toolRegistry.registerBuiltin( + { name: "builtin_tool", description: "内置工具", parameters: { type: "object", properties: {} } }, + builtinExecutor + ); + + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("hello"))); + + // 用 skipBuiltinTools 调用,传入一个脚本工具 + const scriptTools: ToolDefinition[] = [ + { name: "script_tool", description: "脚本工具", parameters: { type: "object", properties: {} } }, + ]; + + // mock callLLM 来检查传入的 tools + let capturedTools: ToolDefinition[] | undefined; + const callLLMSpy = vi + .spyOn(service as any, "callLLM") + .mockImplementation(async (_model: any, params: any, sendEvent: any) => { + capturedTools = params.tools; + sendEvent({ type: "done" }); + return { content: "ok", usage: { inputTokens: 5, outputTokens: 3 } }; + }); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + tools: scriptTools, + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + skipBuiltinTools: true, + }); + + // 传给 callLLM 的 tools 应只有脚本工具,不含内置工具 + expect(capturedTools).toHaveLength(1); + expect(capturedTools![0].name).toBe("script_tool"); + + callLLMSpy.mockRestore(); + }); + + it("skipBuiltinTools 无 tools 传入时 allToolDefs 为空", async () => { + const { service, toolRegistry } = createTestService(); + const events: ChatStreamEvent[] = []; + + // 注册内置工具 + toolRegistry.registerBuiltin( + { name: "builtin_tool", description: "内置工具", parameters: { type: "object", properties: {} } }, + { execute: vi.fn().mockResolvedValue("result") } + ); + + let capturedTools: ToolDefinition[] | undefined; + const callLLMSpy = vi + .spyOn(service as any, "callLLM") + .mockImplementation(async (_model: any, params: any, sendEvent: any) => { + capturedTools = params.tools; + sendEvent({ type: "done" }); + return { content: "ok", usage: { inputTokens: 5, outputTokens: 3 } }; + }); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + // 不传 tools + maxIterations: 5, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + skipBuiltinTools: true, + }); + + // 不应有任何工具 + expect(capturedTools).toBeUndefined(); + + callLLMSpy.mockRestore(); + }); + + it("每轮循环应重新获取工具定义(支持动态注册)", async () => { + const { service, toolRegistry } = createTestService(); + const events: ChatStreamEvent[] = []; + + // 注册 load_skill 工具:第一次调用时动态注册 new_tool + const loadSkillExecutor: ToolExecutor = { + execute: vi.fn().mockImplementation(async () => { + // 模拟 load_skill 动态注册新工具 + toolRegistry.registerBuiltin( + { name: "dynamic_tool", description: "动态注册的工具", parameters: { type: "object", properties: {} } }, + { execute: vi.fn().mockResolvedValue("dynamic result") } + ); + return "skill loaded"; + }), + }; + toolRegistry.registerBuiltin( + { name: "load_skill", description: "加载 Skill", parameters: { type: "object", properties: {} } }, + loadSkillExecutor + ); + + // 第一轮:LLM 调用 load_skill + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeToolCallSSE("call_1", "load_skill", '{"skill_name":"test"}'))); + // 第二轮:LLM 调用 dynamic_tool(动态注册的) + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeToolCallSSE("call_2", "dynamic_tool", "{}"))); + // 第三轮:纯文本结束 + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("完成"))); + + await (service as any).callLLMWithToolLoop({ + model: openaiConfig, + messages: [{ role: "user", content: "test" }], + maxIterations: 10, + sendEvent: (e: ChatStreamEvent) => events.push(e), + signal: new AbortController().signal, + scriptToolCallback: null, + }); + + // fetch 应被调用 3 次(load_skill → dynamic_tool → 完成) + expect(fetchSpy).toHaveBeenCalledTimes(3); + + // 第二次 fetch 的请求体应包含 dynamic_tool 定义 + const secondCallBody = JSON.parse(fetchSpy.mock.calls[1][1]!.body as string); + const toolNames = secondCallBody.tools.map((t: any) => t.function.name); + expect(toolNames).toContain("dynamic_tool"); + + // 应有 done 事件 + expect(events.find((e) => e.type === "done")).toBeDefined(); + + // 清理 + toolRegistry.unregisterBuiltin("load_skill"); + toolRegistry.unregisterBuiltin("dynamic_tool"); + }); +}); + +// ---- handleConversationChat ephemeral 测试 ---- + +describe("handleConversationChat ephemeral 模式", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + // 辅助:创建 mock sender(connect 模式) + function createMockSender() { + const sentMessages: any[] = []; + let onMessageCb: ((msg: any) => void) | null = null; + let onDisconnectCb: (() => void) | null = null; + + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: (cb: (msg: any) => void) => { + onMessageCb = cb; + }, + onDisconnect: (cb: () => void) => { + onDisconnectCb = cb; + }, + disconnect: () => {}, + }; + + const sender = { + isType: (type: any) => type === 1, // GetSenderType.CONNECT = 1 + getConnect: () => mockConn, + }; + + return { sender, sentMessages, mockConn, getOnMessage: () => onMessageCb, getOnDisconnect: () => onDisconnectCb }; + } + + it("ephemeral 模式不加载会话、不持久化", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + fetchSpy.mockResolvedValueOnce( + buildSSEResponse(makeTextSSE("ephemeral reply", { prompt_tokens: 10, completion_tokens: 5 })) + ); + + await (service as any).handleConversationChat( + { + conversationId: "eph-conv-1", + message: "hello", + ephemeral: true, + modelId: "test-openai", + messages: [{ role: "user", content: "hello" }], + system: "你是助手", + scriptUuid: "test-uuid", + }, + sender + ); + + // 不应调用 repo 的 getMessages 或 listConversations(不加载会话) + expect(mockRepo.getMessages).not.toHaveBeenCalled(); + expect(mockRepo.listConversations).not.toHaveBeenCalled(); + // 不应调用 appendMessage(不持久化) + expect(mockRepo.appendMessage).not.toHaveBeenCalled(); + // 不应调用 saveConversation + expect(mockRepo.saveConversation).not.toHaveBeenCalled(); + }); + + it("ephemeral 模式使用传入的 messages 和 system", async () => { + const { service } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("回复", { prompt_tokens: 10, completion_tokens: 5 }))); + + await (service as any).handleConversationChat( + { + conversationId: "eph-conv-2", + message: "你好", + ephemeral: true, + modelId: "test-openai", + messages: [ + { role: "user", content: "上一条" }, + { role: "assistant", content: "上次回复" }, + { role: "user", content: "你好" }, + ], + system: "系统提示", + scriptUuid: "test-uuid", + }, + sender + ); + + // 应该发送了 fetch 请求 + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // 检查 fetch 请求体中的消息 + const fetchBody = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string); + // 应包含 system + 3 条消息 + expect(fetchBody.messages.length).toBeGreaterThanOrEqual(4); + // 第一条应是 system,包含内置提示词 + 用户自定义 + expect(fetchBody.messages[0].role).toBe("system"); + expect(fetchBody.messages[0].content).toContain("You are ScriptCat Agent"); + expect(fetchBody.messages[0].content).toContain("系统提示"); + + // 应该发送了 done 事件 + const doneMsg = sentMessages.find((m) => m.action === "event" && m.data.type === "done"); + expect(doneMsg).toBeDefined(); + }); + + it("ephemeral 模式使用 skipBuiltinTools", async () => { + const { service, toolRegistry } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + // 注册内置工具 + toolRegistry.registerBuiltin( + { name: "dom_read_page", description: "读取页面", parameters: { type: "object", properties: {} } }, + { execute: vi.fn().mockResolvedValue("page content") } + ); + + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("ok", { prompt_tokens: 5, completion_tokens: 3 }))); + + await (service as any).handleConversationChat( + { + conversationId: "eph-conv-3", + message: "test", + ephemeral: true, + modelId: "test-openai", + messages: [{ role: "user", content: "test" }], + scriptUuid: "test-uuid", + }, + sender + ); + + // fetch 请求体中不应包含内置工具 + const fetchBody = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string); + expect(fetchBody.tools).toBeUndefined(); + + // 应正常完成 + const doneMsg = sentMessages.find((m) => m.action === "event" && m.data.type === "done"); + expect(doneMsg).toBeDefined(); + }); + + it("ephemeral 模式带脚本工具时 tools 传入 callLLMWithToolLoop", async () => { + const { service } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + // mock callLLMWithToolLoop 来验证参数 + let capturedParams: any; + const loopSpy = vi.spyOn(service as any, "callLLMWithToolLoop").mockImplementation(async (params: any) => { + capturedParams = params; + params.sendEvent({ type: "done", usage: { inputTokens: 5, outputTokens: 3 } }); + }); + + await (service as any).handleConversationChat( + { + conversationId: "eph-conv-4", + message: "test", + ephemeral: true, + modelId: "test-openai", + messages: [{ role: "user", content: "test" }], + tools: [{ name: "my_script_tool", description: "脚本工具", parameters: {} }], + scriptUuid: "test-uuid", + }, + sender + ); + + // 验证 callLLMWithToolLoop 收到正确参数 + expect(capturedParams.skipBuiltinTools).toBe(true); + expect(capturedParams.tools).toHaveLength(1); + expect(capturedParams.tools[0].name).toBe("my_script_tool"); + expect(capturedParams.scriptToolCallback).not.toBeNull(); + // 不应有 conversationId(ephemeral 不持久化) + expect(capturedParams.conversationId).toBeUndefined(); + + // 应发送 done 事件 + expect(sentMessages.some((m) => m.action === "event" && m.data.type === "done")).toBe(true); + + loopSpy.mockRestore(); + }); + + it("ephemeral 模式无 system 时不添加 system 消息", async () => { + const { service } = createTestService(); + const { sender } = createMockSender(); + + fetchSpy.mockResolvedValueOnce(buildSSEResponse(makeTextSSE("ok", { prompt_tokens: 5, completion_tokens: 3 }))); + + await (service as any).handleConversationChat( + { + conversationId: "eph-conv-5", + message: "test", + ephemeral: true, + modelId: "test-openai", + messages: [{ role: "user", content: "test" }], + // 无 system + scriptUuid: "test-uuid", + }, + sender + ); + + const fetchBody = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string); + // 应有 system 消息(内置提示词),即使用户没传 system + const systemMsg = fetchBody.messages.find((m: any) => m.role === "system"); + expect(systemMsg).toBeDefined(); + expect(systemMsg.content).toContain("You are ScriptCat Agent"); + }); +}); diff --git a/src/app/service/agent/core/attachment_resolver.ts b/src/app/service/agent/core/attachment_resolver.ts new file mode 100644 index 000000000..83039f20a --- /dev/null +++ b/src/app/service/agent/core/attachment_resolver.ts @@ -0,0 +1,61 @@ +import type { ChatRequest, AgentModelConfig } from "./types"; +import { isContentBlocks } from "./content_utils"; +import { supportsVision } from "@App/pages/options/routes/AgentChat/model_utils"; + +/** + * 解析消息中 image+vision 的 attachmentId → base64 data URL + * file/audio/image(无vision) 不加载,provider 使用 OPFS 路径引用 + * @param messages 待解析的消息列表 + * @param model 当前模型配置(用于判断是否支持 vision) + * @param getAttachment 通过 attachmentId 异步获取 Blob 的函数(未找到返回 null/undefined) + * @returns resolver 函数:给定 attachmentId 返回 data URL 或 null + */ +export async function resolveAttachments( + messages: ChatRequest["messages"], + model: AgentModelConfig, + getAttachment: (id: string) => Promise +): Promise<(id: string) => string | null> { + const resolved = new Map(); + const mimeTypes = new Map(); + const ids = new Set(); + const hasVision = supportsVision(model); + + for (const m of messages) { + if (isContentBlocks(m.content)) { + for (const block of m.content) { + // 只收集 image + vision 的 attachmentId + if (block.type === "image" && hasVision && "attachmentId" in block) { + ids.add(block.attachmentId); + if (block.mimeType) { + mimeTypes.set(block.attachmentId, block.mimeType); + } + } + } + } + } + + if (ids.size === 0) return () => null; + + for (const id of ids) { + try { + const blob = await getAttachment(id); + if (blob) { + // Blob → base64 data URL(分块拼接,避免 O(n²) 字符串拼接) + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const CHUNK_SIZE = 8192; + const chunks: string[] = []; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + chunks.push(String.fromCharCode(...bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length)))); + } + const b64 = btoa(chunks.join("")); + const mime = mimeTypes.get(id) || blob.type || "application/octet-stream"; + resolved.set(id, `data:${mime};base64,${b64}`); + } + } catch { + // 加载失败,跳过 + } + } + + return (id: string) => resolved.get(id) ?? null; +} diff --git a/src/app/service/agent/core/compact_prompt.test.ts b/src/app/service/agent/core/compact_prompt.test.ts new file mode 100644 index 000000000..a971016a9 --- /dev/null +++ b/src/app/service/agent/core/compact_prompt.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { extractSummary, buildCompactUserPrompt, COMPACT_SYSTEM_PROMPT } from "./compact_prompt"; + +describe("extractSummary", () => { + it("extracts content from tags", () => { + const response = ` +1. **Task Overview**: Build a feature +2. **Current State**: Used React +`; + const result = extractSummary(response); + expect(result).toBe("1. **Task Overview**: Build a feature\n2. **Current State**: Used React"); + }); + + it("returns full content when no tag found", () => { + const response = "Just a plain summary without tags"; + expect(extractSummary(response)).toBe("Just a plain summary without tags"); + }); + + it("handles empty tags", () => { + expect(extractSummary("")).toBe(""); + }); + + it("handles multiline content inside ", () => { + const response = ` +Line 1 +Line 2 +Line 3 +`; + expect(extractSummary(response)).toBe("Line 1\nLine 2\nLine 3"); + }); +}); + +describe("buildCompactUserPrompt", () => { + it("builds prompt without custom instruction", () => { + const prompt = buildCompactUserPrompt(); + expect(prompt).toContain("continuation summary"); + expect(prompt).toContain(""); + expect(prompt).toContain(""); + expect(prompt).not.toContain("Additional summarization instructions"); + }); + + it("包含所有 8 个摘要段落", () => { + const prompt = buildCompactUserPrompt(); + expect(prompt).toContain("**Task Overview**"); + expect(prompt).toContain("**Current State**"); + expect(prompt).toContain("**User Messages**"); + expect(prompt).toContain("**Errors and Fixes**"); + expect(prompt).toContain("**Important Discoveries**"); + expect(prompt).toContain("**Current Work**"); + expect(prompt).toContain("**Next Steps**"); + expect(prompt).toContain("**Context to Preserve**"); + }); + + it("appends custom instruction when provided", () => { + const prompt = buildCompactUserPrompt("只保留代码相关内容"); + expect(prompt).toContain("Additional summarization instructions from the user: 只保留代码相关内容"); + }); +}); + +describe("COMPACT_SYSTEM_PROMPT", () => { + it("is defined and non-empty", () => { + expect(COMPACT_SYSTEM_PROMPT).toBeTruthy(); + expect(COMPACT_SYSTEM_PROMPT.length).toBeGreaterThan(0); + }); +}); diff --git a/src/app/service/agent/core/compact_prompt.ts b/src/app/service/agent/core/compact_prompt.ts new file mode 100644 index 000000000..e9185a533 --- /dev/null +++ b/src/app/service/agent/core/compact_prompt.ts @@ -0,0 +1,61 @@ +export const COMPACT_SYSTEM_PROMPT = `You are a conversation summarizer. Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. This summary will replace the conversation history, enabling efficient task resumption in a new context window.`; + +export function buildCompactUserPrompt(customInstruction?: string): string { + let prompt = `Write a structured, concise, and actionable continuation summary of the conversation so far. First analyze the conversation in tags, then write the summary in tags. + +Include the following sections in your : + +1. **Task Overview** + - The user's core request and success criteria + - Any clarifications or constraints they specified + +2. **Current State** + - What has been completed so far + - Pages visited, data extracted, or actions performed (with URLs/selectors if relevant) + - Key outputs or artifacts produced + +3. **User Messages** + - List ALL user messages that are not tool results + - These are critical for understanding the user's feedback and changing intent + - Include any mid-conversation corrections or preference changes + +4. **Errors and Fixes** + - All errors encountered and how they were resolved + - User feedback on errors (especially "do it differently" instructions) + - What approaches were tried that didn't work (and why) + +5. **Important Discoveries** + - Technical constraints or site-specific quirks uncovered + - Decisions made and their rationale + - Selectors, page structures, or API endpoints discovered that may be needed again + +6. **Current Work** + - Precisely what was being worked on immediately before this summary + - Include specific details: which page, which step, what was the last action + - If a sub-agent was running, what was its task and status + +7. **Next Steps** + - Specific actions needed to complete the task + - Any blockers or open questions to resolve + - Priority order if multiple steps remain + - If there is a next step, describe exactly where you left off to prevent task drift + +8. **Context to Preserve** + - User preferences or style requirements + - Domain-specific details that aren't obvious + - Any promises or commitments made to the user + +Be concise but complete — err on the side of including information that would prevent duplicate work or repeated mistakes.`; + + if (customInstruction) { + prompt += `\n\nAdditional summarization instructions from the user: ${customInstruction}`; + } + + return prompt; +} + +/** 从 LLM 响应中提取 标签内容,跳过 部分 */ +export function extractSummary(content: string): string { + const match = content.match(/([\s\S]*?)<\/summary>/); + return match ? match[1].trim() : content.trim(); +} diff --git a/src/app/service/agent/core/content_utils.test.ts b/src/app/service/agent/core/content_utils.test.ts new file mode 100644 index 000000000..2dd6cce39 --- /dev/null +++ b/src/app/service/agent/core/content_utils.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { getTextContent, normalizeContent, isContentBlocks } from "./content_utils"; +import type { ContentBlock } from "./types"; + +describe("content_utils", () => { + describe("getTextContent", () => { + it("returns string content as-is", () => { + expect(getTextContent("hello world")).toBe("hello world"); + }); + + it("returns empty string for empty string", () => { + expect(getTextContent("")).toBe(""); + }); + + it("extracts text from ContentBlock[]", () => { + const blocks: ContentBlock[] = [ + { type: "text", text: "Hello " }, + { type: "image", attachmentId: "img1", mimeType: "image/png", name: "test.png" }, + { type: "text", text: "world" }, + ]; + expect(getTextContent(blocks)).toBe("Hello world"); + }); + + it("returns empty string for ContentBlock[] with no text blocks", () => { + const blocks: ContentBlock[] = [ + { type: "image", attachmentId: "img1", mimeType: "image/png" }, + { type: "file", attachmentId: "f1", mimeType: "application/pdf", name: "doc.pdf" }, + ]; + expect(getTextContent(blocks)).toBe(""); + }); + + it("returns empty string for empty ContentBlock[]", () => { + expect(getTextContent([])).toBe(""); + }); + + it("handles audio blocks (skipped in text extraction)", () => { + const blocks: ContentBlock[] = [ + { type: "text", text: "Listen: " }, + { type: "audio", attachmentId: "a1", mimeType: "audio/wav", name: "clip.wav", durationMs: 5000 }, + ]; + expect(getTextContent(blocks)).toBe("Listen: "); + }); + }); + + describe("normalizeContent", () => { + it("converts string to TextBlock[]", () => { + expect(normalizeContent("hello")).toEqual([{ type: "text", text: "hello" }]); + }); + + it("returns empty array for empty string", () => { + expect(normalizeContent("")).toEqual([]); + }); + + it("returns ContentBlock[] as-is", () => { + const blocks: ContentBlock[] = [ + { type: "text", text: "hello" }, + { type: "image", attachmentId: "img1", mimeType: "image/png" }, + ]; + expect(normalizeContent(blocks)).toBe(blocks); + }); + + it("returns empty array as-is", () => { + const blocks: ContentBlock[] = []; + expect(normalizeContent(blocks)).toBe(blocks); + }); + }); + + describe("isContentBlocks", () => { + it("returns false for string", () => { + expect(isContentBlocks("hello")).toBe(false); + }); + + it("returns true for ContentBlock[]", () => { + expect(isContentBlocks([{ type: "text", text: "hello" }])).toBe(true); + }); + + it("returns true for empty array", () => { + expect(isContentBlocks([])).toBe(true); + }); + }); +}); diff --git a/src/app/service/agent/core/content_utils.ts b/src/app/service/agent/core/content_utils.ts new file mode 100644 index 000000000..b3f464d31 --- /dev/null +++ b/src/app/service/agent/core/content_utils.ts @@ -0,0 +1,77 @@ +import type { MessageContent, ContentBlock } from "./types"; + +// MIME 类型 → 文件扩展名映射 +const MIME_EXT_MAP: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg", + "image/bmp": "bmp", + "audio/wav": "wav", + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/ogg": "ogg", + "audio/webm": "webm", + "application/pdf": "pdf", + "application/zip": "zip", + "application/json": "json", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", + "application/vnd.ms-excel": "xls", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", + "application/msword": "doc", + "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx", + "application/vnd.ms-powerpoint": "ppt", + "text/plain": "txt", + "text/html": "html", + "text/csv": "csv", +}; + +/** + * 根据 MIME 类型获取文件扩展名 + */ +export function getExtFromMime(mimeType: string): string { + if (MIME_EXT_MAP[mimeType]) return MIME_EXT_MAP[mimeType]; + // 从子类型中提取(去掉非字母数字字符) + const sub = mimeType.split("/")[1]; + return sub ? sub.replace(/[^a-z0-9]/gi, "") : "bin"; +} + +/** + * 判断文件名是否为图片类型(根据扩展名) + */ +export function isImageFileName(name: string): boolean { + return /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(name); +} + +/** + * 从 MessageContent 提取纯文本(用于 copy、搜索、标题生成等) + * - string: 直接返回 + * - ContentBlock[]: 连接所有 TextBlock 的 text + */ +export function getTextContent(content: MessageContent): string { + if (typeof content === "string") return content; + return content + .filter((b): b is Extract => b.type === "text") + .map((b) => b.text) + .join(""); +} + +/** + * 将 MessageContent 统一为 ContentBlock[] + * - string: 转为 [{ type: "text", text }] + * - ContentBlock[]: 原样返回 + */ +export function normalizeContent(content: MessageContent): ContentBlock[] { + if (typeof content === "string") { + return content ? [{ type: "text", text: content }] : []; + } + return content; +} + +/** + * 类型守卫:判断 content 是否为 ContentBlock[] + */ +export function isContentBlocks(content: MessageContent): content is ContentBlock[] { + return Array.isArray(content); +} diff --git a/src/app/service/agent/core/mcp_client.test.ts b/src/app/service/agent/core/mcp_client.test.ts new file mode 100644 index 000000000..7f888258d --- /dev/null +++ b/src/app/service/agent/core/mcp_client.test.ts @@ -0,0 +1,445 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MCPClient } from "./mcp_client"; +import type { MCPServerConfig } from "./types"; + +// Mock fetch +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +function createConfig(overrides?: Partial): MCPServerConfig { + return { + id: "test-server", + name: "Test Server", + url: "https://mcp.example.com/rpc", + enabled: true, + createtime: Date.now(), + updatetime: Date.now(), + ...overrides, + }; +} + +function jsonResponse(result: unknown, headers?: Record): Response { + const h = new Headers({ "Content-Type": "application/json", ...headers }); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result }), { + status: 200, + headers: h, + }); +} + +function jsonErrorResponse(code: number, message: string): Response { + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code, message } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function httpErrorResponse(status: number, body = ""): Response { + return new Response(body, { status, headers: { "Content-Type": "text/plain" } }); +} + +describe("MCPClient", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + describe("initialize", () => { + it("应正确发送 initialize 和 initialized 请求", async () => { + const client = new MCPClient(createConfig()); + + // initialize response + mockFetch.mockResolvedValueOnce( + jsonResponse({ + protocolVersion: "2025-03-26", + capabilities: {}, + serverInfo: { name: "TestServer", version: "1.0" }, + }) + ); + // initialized notification response + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + + await client.initialize(); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + // 第一个请求是 initialize + const firstCall = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(firstCall.method).toBe("initialize"); + expect(firstCall.id).toBeDefined(); + expect(firstCall.params.protocolVersion).toBe("2025-03-26"); + + // 第二个请求是 initialized 通知(无 id) + const secondCall = JSON.parse(mockFetch.mock.calls[1][1].body); + expect(secondCall.method).toBe("notifications/initialized"); + expect(secondCall.id).toBeUndefined(); + + expect(client.isInitialized()).toBe(true); + }); + + it("initialize 响应缺少 protocolVersion 时应抛出错误", async () => { + const client = new MCPClient(createConfig()); + mockFetch.mockResolvedValueOnce(jsonResponse({})); + + await expect(client.initialize()).rejects.toThrow("missing protocolVersion"); + }); + + it("HTTP 错误时应抛出", async () => { + const client = new MCPClient(createConfig()); + mockFetch.mockResolvedValueOnce(httpErrorResponse(500, "Internal Server Error")); + + await expect(client.initialize()).rejects.toThrow("500"); + }); + }); + + describe("listTools", () => { + it("应正确解析 tools/list 响应", async () => { + const client = new MCPClient(createConfig()); + + // initialize + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + + // listTools + mockFetch.mockResolvedValueOnce( + jsonResponse({ + tools: [ + { + name: "search", + description: "Search the web", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ], + }) + ); + + const tools = await client.listTools(); + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe("search"); + expect(tools[0].serverId).toBe("test-server"); + expect(tools[0].description).toBe("Search the web"); + }); + + it("未初始化时应抛出错误", async () => { + const client = new MCPClient(createConfig()); + await expect(client.listTools()).rejects.toThrow("not initialized"); + }); + }); + + describe("callTool", () => { + async function initClient(): Promise { + const client = new MCPClient(createConfig()); + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + return client; + } + + it("应正确发送 tools/call 并返回文本结果", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + content: [{ type: "text", text: "Result from tool" }], + }) + ); + + const result = await client.callTool("search", { query: "hello" }); + expect(result).toBe("Result from tool"); + + const lastCall = JSON.parse(mockFetch.mock.lastCall![1].body); + expect(lastCall.method).toBe("tools/call"); + expect(lastCall.params.name).toBe("search"); + expect(lastCall.params.arguments).toEqual({ query: "hello" }); + }); + + it("isError 时应抛出错误", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + content: [{ type: "text", text: "Something went wrong" }], + isError: true, + }) + ); + + await expect(client.callTool("search", { query: "fail" })).rejects.toThrow("Something went wrong"); + }); + + it("多内容时应返回完整 content 数组", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + content: [ + { type: "text", text: "Part 1" }, + { type: "image", data: "base64data", mimeType: "image/png" }, + ], + }) + ); + + const result = await client.callTool("multi", {}); + expect(Array.isArray(result)).toBe(true); + expect((result as any[]).length).toBe(2); + }); + }); + + describe("listResources / readResource", () => { + async function initClient(): Promise { + const client = new MCPClient(createConfig()); + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + return client; + } + + it("应正确解析 resources/list", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + resources: [{ uri: "file:///docs/readme.md", name: "README", mimeType: "text/markdown" }], + }) + ); + + const resources = await client.listResources(); + expect(resources).toHaveLength(1); + expect(resources[0].uri).toBe("file:///docs/readme.md"); + expect(resources[0].serverId).toBe("test-server"); + }); + + it("应正确读取资源", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + contents: [{ uri: "file:///docs/readme.md", text: "# Hello", mimeType: "text/markdown" }], + }) + ); + + const result = await client.readResource("file:///docs/readme.md"); + expect(result.contents).toHaveLength(1); + expect(result.contents[0].text).toBe("# Hello"); + }); + }); + + describe("listPrompts / getPrompt", () => { + async function initClient(): Promise { + const client = new MCPClient(createConfig()); + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + return client; + } + + it("应正确解析 prompts/list", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + prompts: [ + { + name: "summarize", + description: "Summarize text", + arguments: [{ name: "text", required: true }], + }, + ], + }) + ); + + const prompts = await client.listPrompts(); + expect(prompts).toHaveLength(1); + expect(prompts[0].name).toBe("summarize"); + expect(prompts[0].arguments).toHaveLength(1); + }); + + it("应正确获取 prompt 消息", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + messages: [{ role: "user", content: { type: "text", text: "Summarize: hello world" } }], + }) + ); + + const messages = await client.getPrompt("summarize", { text: "hello world" }); + expect(messages).toHaveLength(1); + expect(messages[0].role).toBe("user"); + }); + }); + + describe("Session ID 管理", () => { + it("应存储并回传 Mcp-Session-Id", async () => { + const client = new MCPClient(createConfig()); + + // initialize 返回 session id + mockFetch.mockResolvedValueOnce( + jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} }, { "Mcp-Session-Id": "session-123" }) + ); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + + // 后续请求应包含 session id + mockFetch.mockResolvedValueOnce(jsonResponse({ tools: [] })); + await client.listTools(); + + const lastCallHeaders = mockFetch.mock.lastCall![1].headers; + expect(lastCallHeaders["Mcp-Session-Id"]).toBe("session-123"); + }); + }); + + describe("JSON-RPC 错误处理", () => { + it("应正确处理 JSON-RPC 错误", async () => { + const client = new MCPClient(createConfig()); + + // initialize 返回 JSON-RPC 错误 + mockFetch.mockResolvedValueOnce(jsonErrorResponse(-32600, "Invalid Request")); + + await expect(client.initialize()).rejects.toThrow("MCP error -32600: Invalid Request"); + }); + }); + + describe("认证头", () => { + it("应设置 Bearer token", async () => { + const client = new MCPClient(createConfig({ apiKey: "sk-test-key" })); + + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBe("Bearer sk-test-key"); + }); + + it("应设置自定义 headers", async () => { + const client = new MCPClient(createConfig({ headers: { "X-Custom": "custom-value" } })); + + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers["X-Custom"]).toBe("custom-value"); + }); + }); + + describe("close", () => { + it("close 后应标记为未初始化", async () => { + const client = new MCPClient(createConfig()); + + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + + expect(client.isInitialized()).toBe(true); + client.close(); + expect(client.isInitialized()).toBe(false); + }); + + it("close 后调用 listTools 应抛出 not initialized", async () => { + const client = new MCPClient(createConfig()); + + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + + client.close(); + await expect(client.listTools()).rejects.toThrow("not initialized"); + }); + + it("close 后调用 callTool 应抛出 not initialized", async () => { + const client = new MCPClient(createConfig()); + + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + + client.close(); + await expect(client.callTool("search", { q: "test" })).rejects.toThrow("not initialized"); + }); + }); + + describe("callTool 边界场景", () => { + async function initClient(): Promise { + const client = new MCPClient(createConfig()); + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + return client; + } + + it("callTool 无参数调用:不传 args → 发送 arguments: {}", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + content: [{ type: "text", text: "no args result" }], + }) + ); + + const result = await client.callTool("ping"); + expect(result).toBe("no args result"); + + const lastCall = JSON.parse(mockFetch.mock.lastCall![1].body); + expect(lastCall.method).toBe("tools/call"); + expect(lastCall.params.name).toBe("ping"); + expect(lastCall.params.arguments).toEqual({}); + }); + + it("callTool JSON-RPC 错误:返回 JSON-RPC error → 抛出 MCP error", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce(jsonErrorResponse(-32601, "Method not found")); + + await expect(client.callTool("unknown_tool", {})).rejects.toThrow("MCP error -32601: Method not found"); + }); + + it("callTool 空 content:返回空 content 数组", async () => { + const client = await initClient(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ + content: [], + }) + ); + + const result = await client.callTool("empty", {}); + // 空 content,不是单个 text,返回整个 content 数组 + expect(Array.isArray(result)).toBe(true); + expect((result as any[]).length).toBe(0); + }); + }); + + describe("请求超时", () => { + it("sendRequest 应传递 AbortSignal.timeout(60s)", async () => { + const client = new MCPClient(createConfig()); + + mockFetch.mockResolvedValueOnce(jsonResponse({ protocolVersion: "2025-03-26", capabilities: {} })); + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + await client.initialize(); + + // 检查 initialize 的 fetch 调用带了 signal + expect(mockFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + // 检查 notification 的 fetch 调用也带了 signal + expect(mockFetch.mock.calls[1][1].signal).toBeInstanceOf(AbortSignal); + }); + }); + + describe("sendNotification 失败", () => { + it("initialize 过程中通知失败应抛出", async () => { + const client = new MCPClient(createConfig()); + + // initialize 请求成功 + mockFetch.mockResolvedValueOnce( + jsonResponse({ + protocolVersion: "2025-03-26", + capabilities: {}, + serverInfo: { name: "TestServer", version: "1.0" }, + }) + ); + // initialized 通知失败(HTTP error) + mockFetch.mockResolvedValueOnce(httpErrorResponse(503, "Service Unavailable")); + + await expect(client.initialize()).rejects.toThrow("503"); + }); + }); +}); diff --git a/src/app/service/agent/core/mcp_client.ts b/src/app/service/agent/core/mcp_client.ts new file mode 100644 index 000000000..dcb15ad5d --- /dev/null +++ b/src/app/service/agent/core/mcp_client.ts @@ -0,0 +1,248 @@ +import type { MCPServerConfig, MCPTool, MCPResource, MCPPrompt, MCPPromptMessage } from "./types"; + +// JSON-RPC 2.0 请求 +type JsonRpcRequest = { + jsonrpc: "2.0"; + id?: number; + method: string; + params?: Record; +}; + +// JSON-RPC 2.0 响应 +type JsonRpcResponse = { + jsonrpc: "2.0"; + id?: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +}; + +// MCP 协议版本 +const MCP_PROTOCOL_VERSION = "2025-03-26"; + +// MCP Client — JSON-RPC 2.0 over Streamable HTTP (POST only) +export class MCPClient { + private nextId = 1; + private sessionId?: string; + private initialized = false; + + constructor(private config: MCPServerConfig) {} + + // 初始化:交换协议版本和能力 + async initialize(): Promise { + const result = (await this.sendRequest("initialize", { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "ScriptCat", version: "1.0.0" }, + })) as { + protocolVersion: string; + capabilities: Record; + serverInfo?: { name: string; version?: string }; + }; + + if (!result || !result.protocolVersion) { + throw new Error("Invalid initialize response: missing protocolVersion"); + } + + // 发送 initialized 通知(无 id = 通知) + await this.sendNotification("notifications/initialized", {}); + this.initialized = true; + } + + // ---- Tools ---- + + async listTools(): Promise { + this.ensureInitialized(); + const result = (await this.sendRequest("tools/list", {})) as { + tools: Array<{ name: string; description?: string; inputSchema: Record }>; + }; + return (result.tools || []).map((t) => ({ + serverId: this.config.id, + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })); + } + + async callTool(name: string, args?: Record): Promise { + this.ensureInitialized(); + const result = (await this.sendRequest("tools/call", { + name, + arguments: args || {}, + })) as { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError?: boolean; + }; + + if (result.isError) { + const errorText = result.content?.map((c) => c.text || "").join("\n") || "Tool call failed"; + throw new Error(errorText); + } + + // 返回文本内容或完整 content + if (result.content?.length === 1 && result.content[0].type === "text") { + return result.content[0].text; + } + return result.content; + } + + // ---- Resources ---- + + async listResources(): Promise { + this.ensureInitialized(); + const result = (await this.sendRequest("resources/list", {})) as { + resources: Array<{ uri: string; name: string; description?: string; mimeType?: string }>; + }; + return (result.resources || []).map((r) => ({ + serverId: this.config.id, + uri: r.uri, + name: r.name, + description: r.description, + mimeType: r.mimeType, + })); + } + + async readResource( + uri: string + ): Promise<{ contents: Array<{ uri: string; text?: string; blob?: string; mimeType?: string }> }> { + this.ensureInitialized(); + return (await this.sendRequest("resources/read", { uri })) as { + contents: Array<{ uri: string; text?: string; blob?: string; mimeType?: string }>; + }; + } + + // ---- Prompts ---- + + async listPrompts(): Promise { + this.ensureInitialized(); + const result = (await this.sendRequest("prompts/list", {})) as { + prompts: Array<{ + name: string; + description?: string; + arguments?: Array<{ name: string; description?: string; required?: boolean }>; + }>; + }; + return (result.prompts || []).map((p) => ({ + serverId: this.config.id, + name: p.name, + description: p.description, + arguments: p.arguments, + })); + } + + async getPrompt(name: string, args?: Record): Promise { + this.ensureInitialized(); + const result = (await this.sendRequest("prompts/get", { + name, + arguments: args || {}, + })) as { + messages: MCPPromptMessage[]; + }; + return result.messages || []; + } + + // ---- Lifecycle ---- + + close(): void { + this.initialized = false; + this.sessionId = undefined; + } + + isInitialized(): boolean { + return this.initialized; + } + + // ---- Internal ---- + + private ensureInitialized(): void { + if (!this.initialized) { + throw new Error("MCPClient not initialized. Call initialize() first."); + } + } + + private buildHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + }; + + // 认证 + if (this.config.apiKey) { + headers["Authorization"] = `Bearer ${this.config.apiKey}`; + } + + // 自定义 headers + if (this.config.headers) { + Object.assign(headers, this.config.headers); + } + + // Session ID + if (this.sessionId) { + headers["Mcp-Session-Id"] = this.sessionId; + } + + return headers; + } + + async sendRequest(method: string, params?: Record): Promise { + const id = this.nextId++; + const body: JsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + params, + }; + + const response = await fetch(this.config.url, { + method: "POST", + headers: this.buildHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(60_000), + }); + + // 存储 session ID + const sessionId = response.headers.get("Mcp-Session-Id"); + if (sessionId) { + this.sessionId = sessionId; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error(`MCP request failed: ${response.status} ${errorText}`); + } + + const json = (await response.json()) as JsonRpcResponse; + + if (json.error) { + throw new Error(`MCP error ${json.error.code}: ${json.error.message}`); + } + + return json.result; + } + + private async sendNotification(method: string, params?: Record): Promise { + const body: JsonRpcRequest = { + jsonrpc: "2.0", + method, + params, + }; + + const response = await fetch(this.config.url, { + method: "POST", + headers: this.buildHeaders(), + body: JSON.stringify(body), + signal: AbortSignal.timeout(60_000), + }); + + // 存储 session ID + const sessionId = response.headers.get("Mcp-Session-Id"); + if (sessionId) { + this.sessionId = sessionId; + } + + // 通知不需要响应体,但检查状态码 + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error(`MCP notification failed: ${response.status} ${errorText}`); + } + } +} diff --git a/src/app/service/agent/core/mcp_tool_executor.test.ts b/src/app/service/agent/core/mcp_tool_executor.test.ts new file mode 100644 index 000000000..d01415e63 --- /dev/null +++ b/src/app/service/agent/core/mcp_tool_executor.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi } from "vitest"; +import { MCPToolExecutor } from "./mcp_tool_executor"; +import type { MCPClient } from "./mcp_client"; + +function createMockClient(callToolResult: unknown): MCPClient { + return { + callTool: vi.fn().mockResolvedValue(callToolResult), + } as unknown as MCPClient; +} + +describe("MCPToolExecutor", () => { + it("应将参数透传给 MCPClient.callTool 并返回结果", async () => { + const client = createMockClient("tool result"); + const executor = new MCPToolExecutor(client, "search"); + + const result = await executor.execute({ query: "hello" }); + + expect(result).toBe("tool result"); + expect(client.callTool).toHaveBeenCalledWith("search", { query: "hello" }); + }); + + it("应正确传递工具名", async () => { + const client = createMockClient({ data: [1, 2, 3] }); + const executor = new MCPToolExecutor(client, "fetch_data"); + + const result = await executor.execute({ limit: 10 }); + + expect(result).toEqual({ data: [1, 2, 3] }); + expect(client.callTool).toHaveBeenCalledWith("fetch_data", { limit: 10 }); + }); + + it("callTool 抛出异常时应向上传播", async () => { + const client = { + callTool: vi.fn().mockRejectedValue(new Error("MCP error")), + } as unknown as MCPClient; + const executor = new MCPToolExecutor(client, "failing_tool"); + + await expect(executor.execute({})).rejects.toThrow("MCP error"); + }); + + it("包含 image 类型的 content 应转换为 ToolResultWithAttachments", async () => { + const mcpContent = [ + { type: "text", text: "Here is the screenshot" }, + { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }, + ]; + const client = createMockClient(mcpContent); + const executor = new MCPToolExecutor(client, "screenshot_tool"); + + const result = await executor.execute({}); + + expect(result).toEqual({ + content: "Here is the screenshot", + attachments: [ + { + type: "image", + name: "image.png", + mimeType: "image/png", + data: "data:image/png;base64,iVBORw0KGgo=", + }, + ], + }); + }); + + it("多个 image 内容应全部转换为附件", async () => { + const mcpContent = [ + { type: "image", data: "abc123", mimeType: "image/jpeg" }, + { type: "image", data: "def456", mimeType: "image/png" }, + ]; + const client = createMockClient(mcpContent); + const executor = new MCPToolExecutor(client, "multi_image"); + + const result = (await executor.execute({})) as any; + + expect(result.content).toBe("Tool completed."); + expect(result.attachments).toHaveLength(2); + expect(result.attachments[0].mimeType).toBe("image/jpeg"); + expect(result.attachments[0].name).toBe("image.jpeg"); + expect(result.attachments[1].mimeType).toBe("image/png"); + }); + + it("只包含 text 的 content 数组应原样返回", async () => { + const mcpContent = [ + { type: "text", text: "Line 1" }, + { type: "text", text: "Line 2" }, + ]; + const client = createMockClient(mcpContent); + const executor = new MCPToolExecutor(client, "text_tool"); + + const result = await executor.execute({}); + + // 没有 image,原样返回 content 数组 + expect(result).toEqual(mcpContent); + }); + + it("非数组结果应原样返回", async () => { + const client = createMockClient("plain string result"); + const executor = new MCPToolExecutor(client, "simple_tool"); + + const result = await executor.execute({}); + + expect(result).toBe("plain string result"); + }); + + it("image 缺少 mimeType 时应默认为 image/png", async () => { + const mcpContent = [{ type: "image", data: "abc123" }]; + const client = createMockClient(mcpContent); + const executor = new MCPToolExecutor(client, "no_mime"); + + const result = (await executor.execute({})) as any; + + expect(result.attachments[0].mimeType).toBe("image/png"); + expect(result.attachments[0].data).toBe("data:image/png;base64,abc123"); + }); +}); diff --git a/src/app/service/agent/core/mcp_tool_executor.ts b/src/app/service/agent/core/mcp_tool_executor.ts new file mode 100644 index 000000000..88dbafc6d --- /dev/null +++ b/src/app/service/agent/core/mcp_tool_executor.ts @@ -0,0 +1,43 @@ +import type { MCPClient } from "./mcp_client"; +import type { ToolExecutor } from "./tool_registry"; +import type { ToolResultWithAttachments } from "./types"; + +// MCP 工具执行器,将 ToolExecutor 接口桥接到 MCPClient.callTool +export class MCPToolExecutor implements ToolExecutor { + constructor( + private client: MCPClient, + private toolName: string + ) {} + + async execute(args: Record): Promise { + const result = await this.client.callTool(this.toolName, args); + + // 检测 MCP 返回的 content 数组是否包含 image 类型 + if (Array.isArray(result)) { + const textParts: string[] = []; + const attachments: ToolResultWithAttachments["attachments"] = []; + + for (const item of result) { + if (item.type === "text" && item.text) { + textParts.push(item.text); + } else if (item.type === "image" && item.data) { + attachments.push({ + type: "image", + name: "image." + (item.mimeType?.split("/")[1] || "png"), + mimeType: item.mimeType || "image/png", + data: `data:${item.mimeType || "image/png"};base64,${item.data}`, + }); + } + } + + if (attachments.length > 0) { + return { + content: textParts.join("\n") || "Tool completed.", + attachments, + } as ToolResultWithAttachments; + } + } + + return result; + } +} diff --git a/src/app/service/agent/core/model_context.test.ts b/src/app/service/agent/core/model_context.test.ts new file mode 100644 index 000000000..39d0e8085 --- /dev/null +++ b/src/app/service/agent/core/model_context.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { getContextWindow, inferContextWindow, DEFAULT_CONTEXT_WINDOW } from "./model_context"; + +describe("getContextWindow", () => { + it("returns user-configured contextWindow when provided", () => { + expect(getContextWindow({ model: "gpt-4o", contextWindow: 50_000 })).toBe(50_000); + }); + + it("matches GPT-4o prefix", () => { + expect(getContextWindow({ model: "gpt-4o" })).toBe(128_000); + expect(getContextWindow({ model: "gpt-4o-mini" })).toBe(128_000); + }); + + it("matches GPT-4.1 prefix before GPT-4", () => { + expect(getContextWindow({ model: "gpt-4.1-nano" })).toBe(1_047_576); + }); + + it("matches GPT-4 Turbo before GPT-4 base", () => { + expect(getContextWindow({ model: "gpt-4-turbo-preview" })).toBe(128_000); + }); + + it("matches GPT-4 base", () => { + expect(getContextWindow({ model: "gpt-4-0613" })).toBe(8_192); + }); + + it("matches Claude models", () => { + expect(getContextWindow({ model: "claude-sonnet-4-20250514" })).toBe(200_000); + expect(getContextWindow({ model: "claude-3-haiku" })).toBe(200_000); + }); + + it("matches Gemini models", () => { + expect(getContextWindow({ model: "gemini-2.0-flash" })).toBe(1_048_576); + }); + + it("matches DeepSeek models", () => { + expect(getContextWindow({ model: "deepseek-chat" })).toBe(64_000); + }); + + it("matches Qwen models", () => { + expect(getContextWindow({ model: "qwen-max" })).toBe(131_072); + }); + + it("matches Llama-4 before Llama base", () => { + expect(getContextWindow({ model: "llama-4-maverick" })).toBe(1_048_576); + expect(getContextWindow({ model: "llama-3.1-70b" })).toBe(131_072); + }); + + it("is case-insensitive", () => { + expect(getContextWindow({ model: "GPT-4O" })).toBe(128_000); + expect(getContextWindow({ model: "Claude-Sonnet-4" })).toBe(200_000); + }); + + it("returns default for unknown models", () => { + expect(getContextWindow({ model: "my-custom-model" })).toBe(DEFAULT_CONTEXT_WINDOW); + }); +}); + +describe("inferContextWindow", () => { + it("returns prefix-matched value", () => { + expect(inferContextWindow("gpt-4o")).toBe(128_000); + }); + + it("returns default for unknown models", () => { + expect(inferContextWindow("unknown-model")).toBe(DEFAULT_CONTEXT_WINDOW); + }); +}); diff --git a/src/app/service/agent/core/model_context.ts b/src/app/service/agent/core/model_context.ts new file mode 100644 index 000000000..46ea4e748 --- /dev/null +++ b/src/app/service/agent/core/model_context.ts @@ -0,0 +1,62 @@ +// 模型上下文窗口大小映射表 +// [前缀, 上下文窗口大小],按前缀长度降序排列以确保最精确匹配优先 +const MODEL_CONTEXT_PREFIXES: Array<[string, number]> = [ + // OpenAI — GPT-5 系列 + ["gpt-5", 400_000], + // OpenAI — GPT-4.1 系列 + ["gpt-4.1", 1_047_576], + // OpenAI — GPT-4o 系列 + ["gpt-4o", 128_000], + // OpenAI — GPT-4 Turbo + ["gpt-4-turbo", 128_000], + // OpenAI — GPT-4 基础 + ["gpt-4", 8_192], + // OpenAI — GPT-3.5 + ["gpt-3.5", 16_385], + // OpenAI — o 系列推理模型 + ["o1", 200_000], + ["o3", 200_000], + ["o4", 200_000], + // Anthropic — Claude 系列(所有版本都是 200K) + ["claude", 200_000], + // Google — Gemini 系列 + ["gemini", 1_048_576], + // Google — Gemma(本地部署) + ["gemma", 128_000], + // DeepSeek + ["deepseek", 64_000], + // Alibaba — Qwen 系列 + ["qwen", 131_072], + ["qwq", 32_000], + // Meta — Llama 系列 + ["llama-4", 1_048_576], + ["llama", 131_072], + // Mistral + ["mistral-nemo", 128_000], + ["mistral", 32_000], + // Microsoft — Phi + ["phi", 16_000], + // GLM + ["glm", 200_000], +]; + +export const DEFAULT_CONTEXT_WINDOW = 128_000; + +/** 获取模型的上下文窗口大小,优先使用用户配置,否则按前缀匹配 */ +export function getContextWindow(config: { model: string; contextWindow?: number }): number { + if (config.contextWindow) return config.contextWindow; + const modelLower = config.model.toLowerCase(); + for (const [prefix, size] of MODEL_CONTEXT_PREFIXES) { + if (modelLower.startsWith(prefix)) return size; + } + return DEFAULT_CONTEXT_WINDOW; +} + +/** 根据模型名称推断上下文窗口大小(不考虑用户配置) */ +export function inferContextWindow(model: string): number { + const modelLower = model.toLowerCase(); + for (const [prefix, size] of MODEL_CONTEXT_PREFIXES) { + if (modelLower.startsWith(prefix)) return size; + } + return DEFAULT_CONTEXT_WINDOW; +} diff --git a/src/app/service/agent/core/opfs_helpers.ts b/src/app/service/agent/core/opfs_helpers.ts new file mode 100644 index 000000000..3e420433b --- /dev/null +++ b/src/app/service/agent/core/opfs_helpers.ts @@ -0,0 +1,109 @@ +// OPFS 工作区公共辅助函数 +// 供 opfs_tools、agent_dom 等模块复用 + +export const WORKSPACE_ROOT = "agents/workspace"; + +/** Strip leading `/`, reject `..` segments */ +export function sanitizePath(raw: string): string { + const stripped = raw.replace(/^\/+/, ""); + const segments = stripped.split("/").filter(Boolean); + for (const seg of segments) { + if (seg === "..") { + throw new Error(`Invalid path: ".." is not allowed`); + } + } + return segments.join("/"); +} + +/** Navigate into nested directories, creating them as needed */ +export async function getDirectory( + root: FileSystemDirectoryHandle, + path: string, + create = false +): Promise { + const segments = path.split("/").filter(Boolean); + let dir = root; + for (const seg of segments) { + dir = await dir.getDirectoryHandle(seg, { create }); + } + return dir; +} + +/** Get the workspace root directory handle */ +export async function getWorkspaceRoot(create = false): Promise { + const opfsRoot = await navigator.storage.getDirectory(); + return getDirectory(opfsRoot, WORKSPACE_ROOT, create); +} + +/** Split a sanitized path into parent directory path and filename */ +export function splitPath(sanitized: string): { dirPath: string; fileName: string } { + const lastSlash = sanitized.lastIndexOf("/"); + if (lastSlash === -1) { + return { dirPath: "", fileName: sanitized }; + } + return { + dirPath: sanitized.substring(0, lastSlash), + fileName: sanitized.substring(lastSlash + 1), + }; +} + +/** 将 data URL 解码为二进制 Uint8Array */ +export function decodeDataUrl(dataUrl: string): { data: Uint8Array; mimeType: string } { + const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + if (!match) { + throw new Error("Invalid data URL format"); + } + const mimeType = match[1]; + const base64 = match[2]; + const binaryStr = atob(base64); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + return { data: bytes, mimeType }; +} + +/** 检测字符串是否是 data URL */ +export function isDataUrl(str: string): boolean { + return /^data:[^;]+;base64,/.test(str); +} + +/** 将二进制数据写入 OPFS workspace 指定路径 */ +export async function writeWorkspaceFile( + path: string, + data: Uint8Array | Blob | string +): Promise<{ path: string; size: number }> { + const safePath = sanitizePath(path); + if (!safePath) throw new Error("path is required"); + + // data URL 字符串自动解码为二进制 + if (typeof data === "string" && isDataUrl(data)) { + const decoded = decodeDataUrl(data); + data = decoded.data; + } + + const workspace = await getWorkspaceRoot(true); + const { dirPath, fileName } = splitPath(safePath); + const dir = dirPath ? await getDirectory(workspace, dirPath, true) : workspace; + const fileHandle = await dir.getFileHandle(fileName, { create: true }); + const writable = await fileHandle.createWritable(); + + if (data instanceof Blob) { + await writable.write(data); + } else if (data instanceof Uint8Array) { + await writable.write(data.buffer as ArrayBuffer); + } else { + await writable.write(data); + } + await writable.close(); + + let size: number; + if (data instanceof Blob) { + size = data.size; + } else if (data instanceof Uint8Array) { + size = data.byteLength; + } else { + size = new Blob([data]).size; + } + return { path: safePath, size }; +} diff --git a/src/app/service/agent/core/providers/anthropic.test.ts b/src/app/service/agent/core/providers/anthropic.test.ts new file mode 100644 index 000000000..ce4308b38 --- /dev/null +++ b/src/app/service/agent/core/providers/anthropic.test.ts @@ -0,0 +1,535 @@ +import { describe, it, expect } from "vitest"; +import { buildAnthropicRequest, parseAnthropicStream } from "./anthropic"; +import type { AgentModelConfig } from "../types"; +import type { ChatRequest, ChatStreamEvent } from "../types"; + +const config: AgentModelConfig = { + id: "test", + name: "Test", + provider: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + apiKey: "sk-ant-test", + model: "claude-sonnet-4-20250514", +}; + +describe("buildAnthropicRequest", () => { + it("多个 system 消息应合并为单个", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [ + { role: "system", content: "你是助手" }, + { role: "system", content: "请用中文回答" }, + { role: "user", content: "你好" }, + ], + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + expect(body.system).toEqual([ + { type: "text", text: "你是助手" }, + { type: "text", text: "请用中文回答", cache_control: { type: "ephemeral" } }, + ]); + expect(body.messages).toHaveLength(1); + expect(body.messages[0].role).toBe("user"); + }); + + it("无 system 消息时不包含 system 字段", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + expect(body.system).toBeUndefined(); + }); + + it("apiBaseUrl 为空时使用默认 URL", () => { + const noBaseConfig = { ...config, apiBaseUrl: "" }; + const { url } = buildAnthropicRequest(noBaseConfig, { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }); + expect(url).toBe("https://api.anthropic.com/v1/messages"); + }); + + it("assistant 消息带 toolCalls 时应转换为 content blocks 格式", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [ + { role: "user", content: "天气" }, + { + role: "assistant", + content: "让我查一下", + toolCalls: [{ id: "toolu_1", name: "get_weather", arguments: '{"city":"北京"}' }], + }, + { role: "tool", content: '{"temp":25}', toolCallId: "toolu_1" }, + ], + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + // messages[0] 是 user, messages[1] 是 assistant + const assistantMsg = body.messages[1]; + expect(assistantMsg.role).toBe("assistant"); + expect(assistantMsg.content).toHaveLength(2); + expect(assistantMsg.content[0]).toEqual({ type: "text", text: "让我查一下" }); + expect(assistantMsg.content[1].type).toBe("tool_use"); + expect(assistantMsg.content[1].name).toBe("get_weather"); + expect(assistantMsg.content[1].input).toEqual({ city: "北京" }); + }); + + it("assistant 消息仅有 toolCalls 无 content 时不应包含 text block", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [ + { role: "user", content: "天气" }, + { + role: "assistant", + content: "", + toolCalls: [{ id: "toolu_1", name: "get_weather", arguments: '{"city":"北京"}' }], + }, + ], + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + // messages[0] 是 user "天气", messages[1] 是 assistant + const assistantMsg = body.messages[1]; + expect(assistantMsg.content).toHaveLength(1); + expect(assistantMsg.content[0].type).toBe("tool_use"); + }); + + it("应设置正确的 Anthropic 头", () => { + const { init } = buildAnthropicRequest(config, { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }); + const headers = init.headers as Record; + expect(headers["x-api-key"]).toBe("sk-ant-test"); + expect(headers["anthropic-version"]).toBe("2023-06-01"); + expect(headers["anthropic-dangerous-direct-browser-access"]).toBe("true"); + expect(headers["Content-Type"]).toBe("application/json"); + }); + + it("工具定义应使用 input_schema 而非 parameters", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + tools: [ + { + name: "test_tool", + description: "测试", + parameters: { type: "object", properties: { x: { type: "number" } } }, + }, + ], + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + expect(body.tools[0].input_schema).toBeDefined(); + expect(body.tools[0].parameters).toBeUndefined(); + expect(body.tools[0].type).toBeUndefined(); + // 最后一个工具应带 cache_control + expect(body.tools[0].cache_control).toEqual({ type: "ephemeral" }); + }); + + it("cache: false 时不应添加 cache_control", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [ + { role: "system", content: "你是助手" }, + { role: "user", content: "hi" }, + ], + tools: [ + { + name: "test_tool", + description: "测试", + parameters: { type: "object", properties: { x: { type: "number" } } }, + }, + ], + cache: false, + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + // system block 不应有 cache_control + expect(body.system[0].cache_control).toBeUndefined(); + // tool 不应有 cache_control + expect(body.tools[0].cache_control).toBeUndefined(); + }); + + it("默认 max_tokens 为 16384,应设置 stream", () => { + const { init } = buildAnthropicRequest(config, { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }); + const body = JSON.parse(init.body as string); + expect(body.max_tokens).toBe(16384); + expect(body.stream).toBe(true); + }); + + it("配置 maxTokens 时应设置 max_tokens", () => { + const configWithMax = { ...config, maxTokens: 4096 }; + const { init } = buildAnthropicRequest(configWithMax, { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }); + const body = JSON.parse(init.body as string); + expect(body.max_tokens).toBe(4096); + }); + + it("tool 消息无 toolCallId 时应按普通消息处理", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [ + { role: "user", content: "hi" }, + { role: "tool", content: "result" }, // 无 toolCallId + ], + }; + + const { init } = buildAnthropicRequest(config, request); + const body = JSON.parse(init.body as string); + + // 无 toolCallId 时不转换为 tool_result + const toolMsg = body.messages[1]; + expect(toolMsg.role).toBe("tool"); + expect(toolMsg.content).toBe("result"); + }); +}); + +// 辅助函数:创建 mock ReadableStreamDefaultReader +function createMockReader(chunks: string[]): ReadableStreamDefaultReader { + const encoder = new TextEncoder(); + let index = 0; + return { + read: async () => { + if (index < chunks.length) { + return { done: false, value: encoder.encode(chunks[index++]) }; + } + return { done: true, value: undefined } as any; + }, + cancel: async () => {}, + closed: Promise.resolve(undefined), + releaseLock: () => {}, + }; +} + +describe("parseAnthropicStream", () => { + it("应正确解析 content_block_delta (text_delta)", async () => { + const reader = createMockReader([ + 'event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"Hello"}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":" World"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(3); + expect(events[0]).toEqual({ type: "content_delta", delta: "Hello" }); + expect(events[1]).toEqual({ type: "content_delta", delta: " World" }); + expect(events[2]).toEqual({ type: "done" }); + }); + + it("应正确解析 tool_use 事件", async () => { + const reader = createMockReader([ + 'event: content_block_start\ndata: {"content_block":{"type":"tool_use","id":"toolu_1","name":"get_weather"}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"input_json_delta","partial_json":"{\\"city\\""}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"input_json_delta","partial_json":":\\"北京\\"}"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events[0].type).toBe("tool_call_start"); + if (events[0].type === "tool_call_start") { + expect(events[0].toolCall.id).toBe("toolu_1"); + expect(events[0].toolCall.name).toBe("get_weather"); + expect(events[0].toolCall.arguments).toBe(""); + } + expect(events[1].type).toBe("tool_call_delta"); + expect(events[2].type).toBe("tool_call_delta"); + }); + + it("应正确解析 thinking_delta 事件", async () => { + const reader = createMockReader([ + 'event: content_block_start\ndata: {"content_block":{"type":"thinking"}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"thinking_delta","thinking":"让我想想..."}}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events[0]).toEqual({ type: "thinking_delta", delta: "让我想想..." }); + expect(events[1]).toEqual({ type: "done" }); + }); + + it("应正确处理 message_delta 中的 usage", async () => { + const reader = createMockReader([ + 'event: message_delta\ndata: {"usage":{"input_tokens":100,"output_tokens":50}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("done"); + if (events[0].type === "done") { + expect(events[0].usage).toEqual({ + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: undefined, + cacheReadInputTokens: undefined, + }); + } + }); + + it("应合并 message_start 和 message_delta 的 usage(含 cache 信息)", async () => { + const reader = createMockReader([ + 'event: message_start\ndata: {"message":{"usage":{"input_tokens":200,"cache_creation_input_tokens":50,"cache_read_input_tokens":100}}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"hi"}}\n\n', + 'event: message_delta\ndata: {"usage":{"output_tokens":30}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ type: "content_delta", delta: "hi" }); + expect(events[1].type).toBe("done"); + if (events[1].type === "done") { + expect(events[1].usage).toEqual({ + inputTokens: 200, + outputTokens: 30, + cacheCreationInputTokens: 50, + cacheReadInputTokens: 100, + }); + } + }); + + it("应正确处理 error 事件", async () => { + const reader = createMockReader(['event: error\ndata: {"error":{"message":"Overloaded"}}\n\n']); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + if (events[0].type === "error") { + expect(events[0].message).toBe("Overloaded"); + } + }); + + it("error 事件无 message 时使用默认错误信息", async () => { + const reader = createMockReader(['event: error\ndata: {"error":{}}\n\n']); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events[0].type).toBe("error"); + if (events[0].type === "error") { + expect(events[0].message).toBe("Anthropic API error"); + } + }); + + it("signal 已中止时应停止读取", async () => { + const controller = new AbortController(); + controller.abort(); + + const reader = createMockReader([ + 'event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"hi"}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(0); + }); + + it("读取错误时应发送 error 事件", async () => { + const reader = { + read: async () => { + throw new Error("Connection reset"); + }, + cancel: async () => {}, + closed: Promise.resolve(undefined), + releaseLock: () => {}, + } as any; + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + if (events[0].type === "error") { + expect(events[0].message).toBe("Connection reset"); + } + }); + + it("读取错误但 signal 已中止时不应发送 error", async () => { + const controller = new AbortController(); + const reader = { + read: async () => { + controller.abort(); + throw new Error("Aborted"); + }, + cancel: async () => {}, + closed: Promise.resolve(undefined), + releaseLock: () => {}, + } as any; + + const events: ChatStreamEvent[] = []; + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(0); + }); + + it("应正确解析图片生成流(content_block_start image → image_delta → content_block_stop)", async () => { + const reader = createMockReader([ + 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"image","source":{"type":"base64","media_type":"image/png"}}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"image_delta","data":"iVBORw0KGgo"}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"image_delta","data":"AAAANSUhEUg"}}\n\n', + 'event: content_block_stop\ndata: {"index":0}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + // 1. content_block_start + expect(events[0].type).toBe("content_block_start"); + if (events[0].type === "content_block_start") { + expect(events[0].block.type).toBe("image"); + expect(events[0].block.mimeType).toBe("image/png"); + } + + // 2. content_block_complete(base64 拼接) + expect(events[1].type).toBe("content_block_complete"); + if (events[1].type === "content_block_complete") { + expect(events[1].block.type).toBe("image"); + expect(events[1].block.mimeType).toBe("image/png"); + expect(events[1].block.attachmentId).toBeTruthy(); + expect(events[1].data).toBe("data:image/png;base64,iVBORw0KGgoAAAANSUhEUg"); + } + + // 3. done + expect(events[2]).toEqual({ type: "done" }); + }); + + it("图片生成后应正常处理后续文本", async () => { + const reader = createMockReader([ + 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"image","source":{"type":"base64","media_type":"image/jpeg"}}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"image_delta","data":"/9j/4AAQ"}}\n\n', + 'event: content_block_stop\ndata: {"index":0}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"这是生成的图片"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events[0].type).toBe("content_block_start"); + expect(events[1].type).toBe("content_block_complete"); + if (events[1].type === "content_block_complete") { + expect(events[1].data).toBe("data:image/jpeg;base64,/9j/4AAQ"); + } + expect(events[2]).toEqual({ type: "content_delta", delta: "这是生成的图片" }); + expect(events[3]).toEqual({ type: "done" }); + }); + + it("非图片的 content_block_stop 不应触发图片完成事件", async () => { + const reader = createMockReader([ + 'event: content_block_start\ndata: {"content_block":{"type":"thinking"}}\n\n', + 'event: content_block_delta\ndata: {"delta":{"type":"thinking_delta","thinking":"思考中"}}\n\n', + 'event: content_block_stop\ndata: {"index":0}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + expect(events[0]).toEqual({ type: "thinking_delta", delta: "思考中" }); + // content_block_stop 不应产生 content_block_complete + expect(events[1]).toEqual({ type: "done" }); + }); + + it("图片块 source 缺少 media_type 时应默认使用 image/png", async () => { + const reader = createMockReader([ + 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"image","source":{"type":"base64"}}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"image_delta","data":"AAAA"}}\n\n', + 'event: content_block_stop\ndata: {"index":0}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + if (events[0].type === "content_block_start") { + expect(events[0].block.mimeType).toBe("image/png"); + } + if (events[1].type === "content_block_complete") { + expect(events[1].block.mimeType).toBe("image/png"); + expect(events[1].data).toBe("data:image/png;base64,AAAA"); + } + }); + + it("应忽略无法解析的 JSON 数据", async () => { + const reader = createMockReader([ + "event: content_block_delta\ndata: {bad json\n\n", + 'event: content_block_delta\ndata: {"delta":{"type":"text_delta","text":"ok"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseAnthropicStream(reader, (e) => events.push(e), controller.signal); + + // 第一个 JSON 解析失败应忽略 + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ type: "content_delta", delta: "ok" }); + }); +}); diff --git a/src/app/service/agent/core/providers/anthropic.ts b/src/app/service/agent/core/providers/anthropic.ts new file mode 100644 index 000000000..87c5159c7 --- /dev/null +++ b/src/app/service/agent/core/providers/anthropic.ts @@ -0,0 +1,333 @@ +import type { ChatStreamEvent, ChatRequest, ContentBlock } from "../types"; +import type { AgentModelConfig } from "../types"; +import { SSEParser } from "../sse_parser"; +import { isContentBlocks } from "../content_utils"; + +// 将 ContentBlock[] 转换为 Anthropic content 格式 +function convertContentBlocks( + blocks: ContentBlock[], + attachmentResolver?: (id: string) => string | null +): Array> { + const result: Array> = []; + for (const block of blocks) { + switch (block.type) { + case "text": + result.push({ type: "text", text: block.text }); + break; + case "image": { + const data = attachmentResolver?.(block.attachmentId); + if (data) { + // data URL → base64 内容 + const match = data.match(/^data:([^;]+);base64,(.+)$/s); + if (match) { + result.push({ + type: "image", + source: { type: "base64", media_type: match[1], data: match[2] }, + }); + } else { + result.push({ + type: "text", + text: `[Image: ${block.name || "image"}, OPFS path: uploads/${block.attachmentId}]`, + }); + } + } else { + result.push({ + type: "text", + text: `[Image: ${block.name || "image"}, OPFS path: uploads/${block.attachmentId}]`, + }); + } + break; + } + case "file": + result.push({ + type: "text", + text: `[File: ${block.name}${block.size ? ` (${block.size} bytes)` : ""}, OPFS path: uploads/${block.attachmentId}]`, + }); + break; + case "audio": + // Anthropic 暂不支持音频,降级为文本描述 + result.push({ + type: "text", + text: `[Audio: ${block.name || "audio"}${block.durationMs ? ` (${(block.durationMs / 1000).toFixed(1)}s)` : ""}, OPFS path: uploads/${block.attachmentId}]`, + }); + break; + } + } + return result; +} + +// 构造 Anthropic 格式的请求 +export function buildAnthropicRequest( + config: AgentModelConfig, + request: ChatRequest, + attachmentResolver?: (id: string) => string | null +): { url: string; init: RequestInit } { + const baseUrl = config.apiBaseUrl || "https://api.anthropic.com"; + const url = `${baseUrl}/v1/messages`; + const useCache = request.cache !== false; + + // 分离 system 消息和其他消息 + const systemMessages = request.messages.filter((m) => m.role === "system"); + const otherMessages = request.messages.filter((m) => m.role !== "system"); + + // Anthropic 格式:tool 角色消息需要转换为 tool_result content block + // assistant 消息带 toolCalls 时需要转换为 tool_use content blocks + const messages = otherMessages.map((m) => { + if (m.role === "tool" && m.toolCallId) { + return { + role: "user" as const, + content: [ + { + type: "tool_result", + tool_use_id: m.toolCallId, + content: m.content, + }, + ], + }; + } + // assistant 消息带 tool_calls 时,转换为 content blocks 格式 + if (m.role === "assistant" && m.toolCalls && m.toolCalls.length > 0) { + const content: Array> = []; + if (m.content) { + if (isContentBlocks(m.content)) { + content.push(...convertContentBlocks(m.content, attachmentResolver)); + } else { + content.push({ type: "text", text: m.content }); + } + } + for (const tc of m.toolCalls) { + content.push({ + type: "tool_use", + id: tc.id, + name: tc.name, + input: tc.arguments ? JSON.parse(tc.arguments) : {}, + }); + } + return { role: "assistant" as const, content }; + } + // 处理 ContentBlock[] 格式的消息内容 + if (isContentBlocks(m.content)) { + return { role: m.role, content: convertContentBlocks(m.content, attachmentResolver) }; + } + return { role: m.role, content: m.content }; + }); + + const body: Record = { + model: config.model, + messages, + stream: true, + }; + + body.max_tokens = config.maxTokens || 16384; + + 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(""), + })); + // 最后一个 system block 加 cache_control(仅在启用缓存时) + if (useCache && systemBlocks.length > 0) { + (systemBlocks[systemBlocks.length - 1] as Record).cache_control = { type: "ephemeral" }; + } + body.system = systemBlocks; + } + + // 添加工具定义 + if (request.tools && request.tools.length > 0) { + const tools = request.tools.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.parameters, + })); + // 最后一个 tool 加 cache_control(仅在启用缓存时) + if (useCache && tools.length > 0) { + (tools[tools.length - 1] as Record).cache_control = { type: "ephemeral" }; + } + body.tools = tools; + } + + 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(); + + // 跟踪 message_start 中的 usage(含 cache 信息),在 message_delta 中合并输出 + let cachedUsage: { inputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number } | null = + null; + + // 跟踪图片块的累积 base64 数据 + let imageBlockData: { index: number; mediaType: string; base64Chunks: string[] } | null = null; + + 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 "message_start": { + // message_start 包含初始 usage(input_tokens, cache 信息) + const usage = json.message?.usage; + if (usage) { + cachedUsage = { + inputTokens: usage.input_tokens || 0, + cacheCreationInputTokens: usage.cache_creation_input_tokens, + cacheReadInputTokens: usage.cache_read_input_tokens, + }; + } + break; + } + 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: "", + }, + }); + } else if (block?.type === "image") { + // 图片生成块开始,记录 index 和 media_type + imageBlockData = { + index: json.index, + mediaType: block.source?.media_type || "image/png", + base64Chunks: [], + }; + onEvent({ + type: "content_block_start", + block: { + type: "image", + mimeType: block.source?.media_type || "image/png", + name: "generated_image", + }, + }); + } + 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, + }); + } else if (delta?.type === "image_delta" && imageBlockData) { + // 累积 base64 数据块 + imageBlockData.base64Chunks.push(delta.data); + } + break; + } + case "content_block_stop": { + // 图片块结束时,拼接 base64 并 emit content_block_complete + if (imageBlockData) { + const fullBase64 = imageBlockData.base64Chunks.join(""); + const dataUrl = `data:${imageBlockData.mediaType};base64,${fullBase64}`; + const ext = imageBlockData.mediaType.split("/")[1] || "png"; + const attachmentId = `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; + onEvent({ + type: "content_block_complete", + block: { + type: "image", + attachmentId, + mimeType: imageBlockData.mediaType, + name: "generated_image", + }, + data: dataUrl, + }); + imageBlockData = null; + } + break; + } + case "message_delta": { + // 消息结束,合并 message_start 的 input usage 和 message_delta 的 output usage + if (json.usage) { + onEvent({ + type: "done", + usage: { + inputTokens: cachedUsage?.inputTokens || json.usage.input_tokens || 0, + outputTokens: json.usage.output_tokens || 0, + cacheCreationInputTokens: cachedUsage?.cacheCreationInputTokens, + cacheReadInputTokens: cachedUsage?.cacheReadInputTokens, + }, + }); + 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" }); + } + })(); +} + +// ---- LLMProvider 接口适配 ---- + +import type { LLMProvider } from "./types"; + +/** Anthropic Claude 格式的 Provider 实现(注册在 providers/index.ts) */ +export const anthropicProvider: LLMProvider = { + name: "anthropic", + buildRequest: (input) => buildAnthropicRequest(input.model, input.request, input.resolver), + parseStream: (reader, onEvent, signal) => parseAnthropicStream(reader, onEvent, signal), +}; diff --git a/src/app/service/agent/core/providers/index.ts b/src/app/service/agent/core/providers/index.ts new file mode 100644 index 000000000..9708795a7 --- /dev/null +++ b/src/app/service/agent/core/providers/index.ts @@ -0,0 +1,8 @@ +// Provider 注册发生在 registry.ts(消费者 import providerRegistry 时自动注册) +export { providerRegistry } from "./registry"; +export type { + LLMProvider, + ProviderBuildRequestInput, + ProviderBuildRequestOutput, + ProviderStreamEventHandler, +} from "./types"; diff --git a/src/app/service/agent/core/providers/openai.test.ts b/src/app/service/agent/core/providers/openai.test.ts new file mode 100644 index 000000000..9c853c772 --- /dev/null +++ b/src/app/service/agent/core/providers/openai.test.ts @@ -0,0 +1,451 @@ +import { describe, it, expect } from "vitest"; +import { buildOpenAIRequest, parseOpenAIStream } from "./openai"; +import type { AgentModelConfig } from "../types"; +import type { ChatRequest, ChatStreamEvent } from "../types"; + +const config: AgentModelConfig = { + id: "test", + name: "Test", + provider: "openai", + apiBaseUrl: "https://api.openai.com/v1", + apiKey: "sk-test", + model: "gpt-4o", +}; + +describe("buildOpenAIRequest", () => { + it("无 apiKey 时不包含 Authorization 头", () => { + const noKeyConfig = { ...config, apiKey: "" }; + const { init } = buildOpenAIRequest(noKeyConfig, { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }); + const headers = init.headers as Record; + expect(headers["Authorization"]).toBeUndefined(); + }); + + it("自定义 apiBaseUrl 时使用自定义 URL", () => { + const customConfig = { ...config, apiBaseUrl: "https://my-proxy.com/api" }; + const { url } = buildOpenAIRequest(customConfig, { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }); + expect(url).toBe("https://my-proxy.com/api/chat/completions"); + }); + + it("apiBaseUrl 为空时使用默认 URL", () => { + const noBaseConfig = { ...config, apiBaseUrl: "" }; + const { url } = buildOpenAIRequest(noBaseConfig, { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }); + expect(url).toBe("https://api.openai.com/v1/chat/completions"); + }); + + it("assistant 消息带 toolCalls 时应转换为 OpenAI 格式", () => { + const request: ChatRequest = { + conversationId: "c1", + modelId: "test", + messages: [ + { role: "user", content: "天气" }, + { + role: "assistant", + content: "", + toolCalls: [{ id: "call_1", name: "get_weather", arguments: '{"city":"北京"}' }], + }, + { role: "tool", content: '{"temp":25}', toolCallId: "call_1" }, + ], + }; + + const { init } = buildOpenAIRequest(config, request); + const body = JSON.parse(init.body as string); + + // assistant 消息应包含 tool_calls + const assistantMsg = body.messages[1]; + expect(assistantMsg.tool_calls).toHaveLength(1); + expect(assistantMsg.tool_calls[0].type).toBe("function"); + expect(assistantMsg.tool_calls[0].function.name).toBe("get_weather"); + expect(assistantMsg.tool_calls[0].function.arguments).toBe('{"city":"北京"}'); + + // tool 消息应包含 tool_call_id + const toolMsg = body.messages[2]; + expect(toolMsg.role).toBe("tool"); + expect(toolMsg.tool_call_id).toBe("call_1"); + }); + + it("无 tools 时不包含 tools 字段", () => { + const { init } = buildOpenAIRequest(config, { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }); + const body = JSON.parse(init.body as string); + expect(body.tools).toBeUndefined(); + }); + + it("应设置 stream 和 stream_options", () => { + const { init } = buildOpenAIRequest(config, { + conversationId: "c1", + modelId: "test", + messages: [{ role: "user", content: "hi" }], + }); + const body = JSON.parse(init.body as string); + expect(body.stream).toBe(true); + expect(body.stream_options).toEqual({ include_usage: true }); + }); +}); + +// 辅助函数:创建 mock ReadableStreamDefaultReader +function createMockReader(chunks: string[]): ReadableStreamDefaultReader { + const encoder = new TextEncoder(); + let index = 0; + return { + read: async () => { + if (index < chunks.length) { + return { done: false, value: encoder.encode(chunks[index++]) }; + } + return { done: true, value: undefined } as any; + }, + cancel: async () => {}, + closed: Promise.resolve(undefined), + releaseLock: () => {}, + }; +} + +describe("parseOpenAIStream", () => { + it("应正确解析 content_delta 事件", async () => { + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":" World"}}]}\n\n', + "data: [DONE]\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(3); + expect(events[0]).toEqual({ type: "content_delta", delta: "Hello" }); + expect(events[1]).toEqual({ type: "content_delta", delta: " World" }); + expect(events[2]).toEqual({ type: "done" }); + }); + + it("应正确解析 tool_call_start 和 tool_call_delta", async () => { + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"tool_calls":[{"id":"call_1","function":{"name":"get_weather","arguments":""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"{\\"city\\""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":":\\"北京\\"}"}}]}}]}\n\n', + "data: [DONE]\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events[0].type).toBe("tool_call_start"); + if (events[0].type === "tool_call_start") { + expect(events[0].toolCall.name).toBe("get_weather"); + expect(events[0].toolCall.id).toBe("call_1"); + } + expect(events[1].type).toBe("tool_call_delta"); + expect(events[2].type).toBe("tool_call_delta"); + }); + + it("应正确处理 usage 信息", async () => { + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', + 'data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(2); + expect(events[1].type).toBe("done"); + if (events[1].type === "done") { + expect(events[1].usage).toEqual({ inputTokens: 10, outputTokens: 5 }); + } + }); + + it("应正确处理含 cached_tokens 的 usage 信息", async () => { + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', + 'data: {"usage":{"prompt_tokens":100,"completion_tokens":20,"prompt_tokens_details":{"cached_tokens":80}}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(2); + expect(events[1].type).toBe("done"); + if (events[1].type === "done") { + expect(events[1].usage).toEqual({ inputTokens: 100, outputTokens: 20, cacheReadInputTokens: 80 }); + } + }); + + it("应正确处理 API 错误响应", async () => { + const reader = createMockReader(['data: {"error":{"message":"Rate limit exceeded"}}\n\n']); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + if (events[0].type === "error") { + expect(events[0].message).toBe("Rate limit exceeded"); + } + }); + + it("应忽略无 choices 的事件", async () => { + const reader = createMockReader([ + 'data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk"}\n\n', + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n', + "data: [DONE]\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ type: "content_delta", delta: "ok" }); + }); + + it("应忽略无法解析的 JSON", async () => { + const reader = createMockReader([ + "data: {invalid json\n\n", + 'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n', + "data: [DONE]\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(2); + expect(events[0]).toEqual({ type: "content_delta", delta: "ok" }); + }); + + it("signal 已中止时应停止读取", async () => { + const controller = new AbortController(); + controller.abort(); + + const reader = createMockReader(['data: {"choices":[{"delta":{"content":"hello"}}]}\n\n']); + + const events: ChatStreamEvent[] = []; + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(0); + }); + + it("读取错误时应发送 error 事件", async () => { + const reader = { + read: async () => { + throw new Error("Network error"); + }, + cancel: async () => {}, + closed: Promise.resolve(undefined), + releaseLock: () => {}, + } as any; + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe("error"); + if (events[0].type === "error") { + expect(events[0].message).toBe("Network error"); + } + }); + + it("读取错误但 signal 已中止时不应发送 error 事件", async () => { + const controller = new AbortController(); + const reader = { + read: async () => { + controller.abort(); + throw new Error("Aborted"); + }, + cancel: async () => {}, + closed: Promise.resolve(undefined), + releaseLock: () => {}, + } as any; + + const events: ChatStreamEvent[] = []; + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(0); + }); + + it("应正确解析 reasoning_content 为 thinking_delta 事件", async () => { + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":"让我思考"}}]}\n\n', + 'data: {"choices":[{"delta":{"reasoning_content":"一下这个问题"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"这是答案"}}]}\n\n', + "data: [DONE]\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(4); + expect(events[0]).toEqual({ type: "thinking_delta", delta: "让我思考" }); + expect(events[1]).toEqual({ type: "thinking_delta", delta: "一下这个问题" }); + expect(events[2]).toEqual({ type: "content_delta", delta: "这是答案" }); + expect(events[3]).toEqual({ type: "done" }); + }); + + it("reasoning_content 和 content 同时存在时应同时发出两个事件", async () => { + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"reasoning_content":"思考中","content":"回答"}}]}\n\n', + "data: [DONE]\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(3); + expect(events[0]).toEqual({ type: "thinking_delta", delta: "思考中" }); + expect(events[1]).toEqual({ type: "content_delta", delta: "回答" }); + }); + + it("最后一个 chunk 同时包含 usage 和 choices 时应先处理 choices 再处理 usage", async () => { + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"tool_calls":[{"id":"call_1","function":{"name":"search","arguments":""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"{\\"q\\":\\"test\\"}"}}]}}]}\n\n', + // 最后一个 chunk 同时包含 choices(finish_reason)和 usage + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":20}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + // tool_call_start, tool_call_delta, done(with usage) + expect(events).toHaveLength(3); + expect(events[0].type).toBe("tool_call_start"); + expect(events[1].type).toBe("tool_call_delta"); + expect(events[2].type).toBe("done"); + if (events[2].type === "done") { + expect(events[2].usage).toEqual({ inputTokens: 100, outputTokens: 20 }); + } + }); + + it("最后一个 chunk 同时包含 usage 和 tool_call 增量时不应丢失 tool_call 数据", async () => { + // 模拟实际场景:最后一个 chunk 携带 tool_call arguments 增量 + usage + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"tool_calls":[{"id":"call_1","function":{"name":"dom_read_page","arguments":"{\\"tabId\\":123"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":",\\"mode\\":\\"summary\\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":40010,"completion_tokens":154}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(3); + expect(events[0].type).toBe("tool_call_start"); + if (events[0].type === "tool_call_start") { + expect(events[0].toolCall.name).toBe("dom_read_page"); + expect(events[0].toolCall.arguments).toBe('{"tabId":123'); + } + // 关键:最后的 tool_call_delta 不应被 usage 检查吞掉 + expect(events[1].type).toBe("tool_call_delta"); + if (events[1].type === "tool_call_delta") { + expect(events[1].delta).toBe(',"mode":"summary"}'); + } + expect(events[2].type).toBe("done"); + if (events[2].type === "done") { + expect(events[2].usage).toEqual({ inputTokens: 40010, outputTokens: 154 }); + } + }); + + it("每个 chunk 都带 usage 时不应提前终止流(Grok 兼容)", async () => { + // Grok API 在每个 chunk 都附带 usage,不应被当作结束信号 + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"content":"很"},"finish_reason":null,"index":0}],"usage":{"prompt_tokens":100,"completion_tokens":1}}\n\n', + 'data: {"choices":[{"delta":{"content":"抱"},"finish_reason":null,"index":0}],"usage":{"prompt_tokens":100,"completion_tokens":2}}\n\n', + 'data: {"choices":[{"delta":{"content":"歉"},"finish_reason":null,"index":0}],"usage":{"prompt_tokens":100,"completion_tokens":3}}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop","index":0}],"usage":{"prompt_tokens":100,"completion_tokens":3}}\n\n', + "data: [DONE]\n\n", + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + // 应收到 3 个 content_delta + 1 个 done(带最终 usage) + expect(events).toHaveLength(4); + expect(events[0]).toEqual({ type: "content_delta", delta: "很" }); + expect(events[1]).toEqual({ type: "content_delta", delta: "抱" }); + expect(events[2]).toEqual({ type: "content_delta", delta: "歉" }); + expect(events[3].type).toBe("done"); + if (events[3].type === "done") { + expect(events[3].usage).toEqual({ inputTokens: 100, outputTokens: 3 }); + } + }); + + it("每个 chunk 都带 usage 且无 [DONE] 时应在流结束时发出 done", async () => { + // 某些 API 在所有 chunk 带 usage 但不发 [DONE] + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"content":"你好"},"finish_reason":null}],"usage":{"prompt_tokens":50,"completion_tokens":1}}\n\n', + 'data: {"choices":[{"delta":{"content":"世界"},"finish_reason":null}],"usage":{"prompt_tokens":50,"completion_tokens":2}}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":50,"completion_tokens":2}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(3); + expect(events[0]).toEqual({ type: "content_delta", delta: "你好" }); + expect(events[1]).toEqual({ type: "content_delta", delta: "世界" }); + expect(events[2].type).toBe("done"); + if (events[2].type === "done") { + expect(events[2].usage).toEqual({ inputTokens: 50, outputTokens: 2 }); + } + }); + + it("reasoning_content 后跟 tool_calls 应都正确解析", async () => { + const reader = createMockReader([ + 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":"分析页面"}}]}\n\n', + 'data: {"choices":[{"delta":{"reasoning_content":"结构"}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"id":"call_1","function":{"name":"dom_read_page","arguments":"{\\"selector\\":\\".item\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":500,"completion_tokens":50}}\n\n', + ]); + + const events: ChatStreamEvent[] = []; + const controller = new AbortController(); + + await parseOpenAIStream(reader, (e) => events.push(e), controller.signal); + + expect(events).toHaveLength(4); + expect(events[0]).toEqual({ type: "thinking_delta", delta: "分析页面" }); + expect(events[1]).toEqual({ type: "thinking_delta", delta: "结构" }); + expect(events[2].type).toBe("tool_call_start"); + expect(events[3].type).toBe("done"); + if (events[3].type === "done") { + expect(events[3].usage).toEqual({ inputTokens: 500, outputTokens: 50 }); + } + }); +}); diff --git a/src/app/service/agent/core/providers/openai.ts b/src/app/service/agent/core/providers/openai.ts new file mode 100644 index 000000000..e1e22b009 --- /dev/null +++ b/src/app/service/agent/core/providers/openai.ts @@ -0,0 +1,266 @@ +import type { ChatStreamEvent, ChatRequest, ContentBlock } from "../types"; +import type { AgentModelConfig } from "../types"; +import { SSEParser } from "../sse_parser"; +import { isContentBlocks } from "../content_utils"; + +// 将 ContentBlock[] 转换为 OpenAI content 数组格式 +function convertContentBlocks( + blocks: ContentBlock[], + attachmentResolver?: (id: string) => string | null +): Array> { + const result: Array> = []; + for (const block of blocks) { + switch (block.type) { + case "text": + result.push({ type: "text", text: block.text }); + break; + case "image": { + const data = attachmentResolver?.(block.attachmentId); + if (data) { + result.push({ type: "image_url", image_url: { url: data } }); + } else { + result.push({ + type: "text", + text: `[Image: ${block.name || "image"}, OPFS path: uploads/${block.attachmentId}]`, + }); + } + break; + } + case "file": + result.push({ + type: "text", + text: `[File: ${block.name}${block.size ? ` (${block.size} bytes)` : ""}, OPFS path: uploads/${block.attachmentId}]`, + }); + break; + case "audio": { + const data = attachmentResolver?.(block.attachmentId); + if (data) { + const match = data.match(/^data:([^;]+);base64,(.+)$/s); + if (match) { + // 从 mimeType 提取格式 (e.g. "audio/wav" → "wav") + const format = block.mimeType.split("/")[1] || "wav"; + result.push({ type: "input_audio", input_audio: { data: match[2], format } }); + } else { + result.push({ + type: "text", + text: `[Audio: ${block.name || "audio"}, OPFS path: uploads/${block.attachmentId}]`, + }); + } + } else { + result.push({ + type: "text", + text: `[Audio: ${block.name || "audio"}, OPFS path: uploads/${block.attachmentId}]`, + }); + } + break; + } + } + } + return result; +} + +// 构造 OpenAI 兼容格式的请求 +export function buildOpenAIRequest( + config: AgentModelConfig, + request: ChatRequest, + attachmentResolver?: (id: string) => string | null +): { 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 messages = request.messages.map((m) => { + const msg: Record = { role: m.role }; + // 处理 ContentBlock[] 格式的消息内容 + if (isContentBlocks(m.content)) { + msg.content = convertContentBlocks(m.content, attachmentResolver); + } else { + msg.content = m.content; + } + if (m.toolCallId) { + msg.tool_call_id = m.toolCallId; + } + // assistant 消息带 tool_calls 时,转换为 OpenAI 格式 + if (m.toolCalls && m.toolCalls.length > 0) { + msg.tool_calls = m.toolCalls.map((tc) => ({ + id: tc.id, + type: "function", + function: { name: tc.name, arguments: tc.arguments }, + })); + } + return msg; + }); + + const body: Record = { + model: config.model, + messages, + stream: true, + 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) => ({ + type: "function", + function: { + name: t.name, + description: t.description, + parameters: t.parameters, + }, + })); + } + + return { + url, + init: { + method: "POST", + headers, + body: JSON.stringify(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 () => { + // 记录最新的 usage 数据(某些 API 如 Grok 在每个 chunk 都带 usage,而非仅最后一个) + let lastUsage: + | { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number } + | undefined; + + 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", usage: lastUsage }); + return; + } + + try { + const json = JSON.parse(sseEvent.data); + + // 处理 API 错误响应 + if (json.error) { + onEvent({ + type: "error", + message: json.error.message || JSON.stringify(json.error), + }); + return; + } + + const choice = json.choices?.[0]; + if (choice) { + const delta = choice.delta; + if (delta) { + // 思考过程增量(reasoning_content 兼容 deepseek / openai o-series) + if (delta.reasoning_content) { + onEvent({ type: "thinking_delta", delta: delta.reasoning_content }); + } + + // 内容增量(可能是字符串或数组,GPT-4o 图片生成时为数组) + if (delta.content) { + if (Array.isArray(delta.content)) { + for (const part of delta.content) { + if (part.type === "text" && part.text) { + onEvent({ type: "content_delta", delta: part.text }); + } else if (part.type === "image_url" && part.image_url?.url) { + // 模型生成的图片,通过 content_block_complete 事件传递 data URL + const dataUrl: string = part.image_url.url; + const mimeMatch = dataUrl.match(/^data:([^;]+);/); + const mimeType = mimeMatch ? mimeMatch[1] : "image/png"; + const ext = mimeType.split("/")[1] || "png"; + const blockId = `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; + onEvent({ + type: "content_block_complete", + block: { type: "image", attachmentId: blockId, mimeType, name: "generated-image" }, + data: dataUrl, + }); + } + } + } else { + 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, + }); + } + } + } + } + } + + // 记录 usage(不作为结束信号,兼容每个 chunk 都带 usage 的 API) + if (json.usage) { + const cachedTokens = json.usage.prompt_tokens_details?.cached_tokens; + lastUsage = { + inputTokens: json.usage.prompt_tokens || 0, + outputTokens: json.usage.completion_tokens || 0, + ...(cachedTokens ? { cacheReadInputTokens: cachedTokens } : {}), + }; + } + } catch { + // 解析失败忽略 + } + } + } + // 流正常结束但没收到 [DONE](某些 API 可能如此) + if (!signal.aborted) { + onEvent({ type: "done", usage: lastUsage }); + } + } catch (e: any) { + if (signal.aborted) return; + onEvent({ type: "error", message: e.message || "Stream read error" }); + } + })(); +} + +// ---- LLMProvider 接口适配 ---- + +import type { LLMProvider } from "./types"; + +/** OpenAI 兼容格式的 Provider 实现(注册在 providers/index.ts) */ +export const openaiProvider: LLMProvider = { + name: "openai", + buildRequest: (input) => buildOpenAIRequest(input.model, input.request, input.resolver), + parseStream: (reader, onEvent, signal) => parseOpenAIStream(reader, onEvent, signal), +}; diff --git a/src/app/service/agent/core/providers/registry.test.ts b/src/app/service/agent/core/providers/registry.test.ts new file mode 100644 index 000000000..c8fd59eec --- /dev/null +++ b/src/app/service/agent/core/providers/registry.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { ProviderRegistry } from "./registry"; +import type { LLMProvider } from "./types"; + +// 辅助函数:创建一个简单的 mock Provider +function makeMockProvider(name: string): LLMProvider { + return { + name, + buildRequest: () => ({ url: "https://example.com", init: { method: "POST" } }), + parseStream: async () => {}, + }; +} + +describe("ProviderRegistry", () => { + let registry: ProviderRegistry; + + beforeEach(() => { + registry = new ProviderRegistry(); + }); + + it("register 后 has 应返回 true", () => { + registry.register(makeMockProvider("openai")); + expect(registry.has("openai")).toBe(true); + }); + + it("未注册的 provider has 应返回 false", () => { + expect(registry.has("unknown")).toBe(false); + }); + + it("get 应返回已注册的 provider", () => { + const p = makeMockProvider("anthropic"); + registry.register(p); + expect(registry.get("anthropic")).toBe(p); + }); + + it("get 未注册的 provider 应返回 undefined", () => { + expect(registry.get("gemini")).toBeUndefined(); + }); + + it("listNames 应返回所有已注册的名称", () => { + registry.register(makeMockProvider("openai")); + registry.register(makeMockProvider("anthropic")); + expect(registry.listNames()).toEqual(expect.arrayContaining(["openai", "anthropic"])); + expect(registry.listNames()).toHaveLength(2); + }); + + it("重复注册同名 provider 应覆盖", () => { + const p1 = makeMockProvider("openai"); + const p2 = makeMockProvider("openai"); + registry.register(p1); + registry.register(p2); + expect(registry.get("openai")).toBe(p2); + expect(registry.listNames()).toHaveLength(1); + }); + + it("listNames 在无注册时应返回空数组", () => { + expect(registry.listNames()).toEqual([]); + }); +}); diff --git a/src/app/service/agent/core/providers/registry.ts b/src/app/service/agent/core/providers/registry.ts new file mode 100644 index 000000000..4adb81b1d --- /dev/null +++ b/src/app/service/agent/core/providers/registry.ts @@ -0,0 +1,35 @@ +import type { LLMProvider } from "./types"; +import { openaiProvider } from "./openai"; +import { anthropicProvider } from "./anthropic"; + +/** LLM Provider 注册表,支持按 provider 名称查找实现 */ +export class ProviderRegistry { + private providers = new Map(); + + /** 注册一个 Provider(同名会覆盖) */ + register(provider: LLMProvider): void { + this.providers.set(provider.name, provider); + } + + /** 按名称获取 Provider,未注册则返回 undefined */ + get(name: string): LLMProvider | undefined { + return this.providers.get(name); + } + + /** 判断指定名称的 Provider 是否已注册 */ + has(name: string): boolean { + return this.providers.has(name); + } + + /** 返回所有已注册的 Provider 名称列表 */ + listNames(): string[] { + return Array.from(this.providers.keys()); + } +} + +export const providerRegistry = new ProviderRegistry(); + +// 注册内置 Provider(与 registry 同模块,消费者使用 providerRegistry 即触发注册, +// 避免 bundler 对纯副作用导入的 tree-shake) +providerRegistry.register(openaiProvider); +providerRegistry.register(anthropicProvider); diff --git a/src/app/service/agent/core/providers/types.ts b/src/app/service/agent/core/providers/types.ts new file mode 100644 index 000000000..8377c033a --- /dev/null +++ b/src/app/service/agent/core/providers/types.ts @@ -0,0 +1,39 @@ +import type { AgentModelConfig, ChatRequest, ChatStreamEvent } from "../types"; + +/** 构建 HTTP 请求所需的输入参数 */ +export interface ProviderBuildRequestInput { + /** 模型配置 */ + model: AgentModelConfig; + /** LLM 聊天请求 */ + request: ChatRequest; + /** 附件 ID → base64 data URL 解析器 */ + resolver?: (attachmentId: string) => string | null; +} + +/** 构建 HTTP 请求的输出 */ +export interface ProviderBuildRequestOutput { + url: string; + init: RequestInit; +} + +/** 流式事件推送回调 */ +export type ProviderStreamEventHandler = (event: ChatStreamEvent) => void; + +/** + * LLM Provider 抽象接口。 + * 每个 Provider 实现此接口后注册到 providerRegistry,callLLM 通过注册表查找。 + */ +export interface LLMProvider { + /** Provider 标识名,用于注册与查找(如 "openai"、"anthropic") */ + readonly name: string; + + /** 构建 fetch 请求所需的 url 与 RequestInit */ + buildRequest(input: ProviderBuildRequestInput): ProviderBuildRequestOutput | Promise; + + /** 解析 SSE 流式响应,通过 onEvent 推送 ChatStreamEvent */ + parseStream( + reader: ReadableStreamDefaultReader, + onEvent: ProviderStreamEventHandler, + signal: AbortSignal + ): Promise; +} diff --git a/src/app/service/agent/core/skill_script_executor.test.ts b/src/app/service/agent/core/skill_script_executor.test.ts new file mode 100644 index 000000000..29e9ba737 --- /dev/null +++ b/src/app/service/agent/core/skill_script_executor.test.ts @@ -0,0 +1,575 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + SkillScriptExecutor, + getSkillScriptNameByUuid, + getSkillScriptGrantsByUuid, + SKILL_SCRIPT_UUID_PREFIX, + type RequireLoader, +} from "./skill_script_executor"; +import type { SkillScriptRecord } from "./types"; + +function createRecord( + params: SkillScriptRecord["params"] = [], + overrides?: Partial +): SkillScriptRecord { + return { + id: "test-uuid-001", + name: "test_tool", + description: "测试工具", + params, + grants: [], + code: `// ==SkillScript== +// @name test_tool +// ==/SkillScript== +return args.value;`, + installtime: Date.now(), + updatetime: Date.now(), + ...overrides, + }; +} + +// 创建 mock sender,sendMessage 返回 { data: response } +function createMockSender(response: any = "mock_result") { + return { + sendMessage: vi.fn().mockResolvedValue({ data: response }), + } as any; +} + +// 从 mock sender 的调用中提取传给 offscreen 的 params +function getCallParams(sender: any) { + const call = sender.sendMessage.mock.calls[0][0]; + // sendMessage 被调用为 sendMessage({ action, data }) + return call.data; +} + +describe("SkillScriptExecutor", () => { + it("应将 string 类型参数转换为字符串", async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "city", type: "string", required: true, description: "城市" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ city: 123 }); + + const params = getCallParams(sender); + expect(params.args).toEqual({ city: "123" }); + }); + + it("应将 number 类型参数转换为数字", async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "count", type: "number", required: true, description: "数量" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ count: "42" }); + + const params = getCallParams(sender); + expect(params.args).toEqual({ count: 42 }); + }); + + it("应将 boolean 类型参数转换为布尔值", async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "verbose", type: "boolean", required: false, description: "详细模式" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ verbose: "true" }); + + const params = getCallParams(sender); + expect(params.args).toEqual({ verbose: true }); + }); + + it('boolean 类型:非 true/"true" 应转换为 false', async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "verbose", type: "boolean", required: false, description: "详细模式" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ verbose: "false" }); + expect(getCallParams(sender).args).toEqual({ verbose: false }); + + // 重置并测试 0 + sender.sendMessage.mockClear(); + sender.sendMessage.mockResolvedValue({ data: "mock_result" }); + await executor.execute({ verbose: 0 }); + expect(getCallParams(sender).args).toEqual({ verbose: false }); + }); + + it("boolean 类型:true 值应保持为 true", async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "flag", type: "boolean", required: false, description: "标记" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ flag: true }); + + expect(getCallParams(sender).args).toEqual({ flag: true }); + }); + + it("应跳过 undefined 参数", async () => { + const sender = createMockSender(); + const record = createRecord([ + { name: "required_param", type: "string", required: true, description: "必须" }, + { name: "optional_param", type: "string", required: false, description: "可选" }, + ]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ required_param: "hello" }); + + expect(getCallParams(sender).args).toEqual({ required_param: "hello" }); + }); + + it("应忽略不在定义中的额外参数", async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "city", type: "string", required: true, description: "城市" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ city: "北京", extra: "should_be_ignored" }); + + expect(getCallParams(sender).args).toEqual({ city: "北京" }); + }); + + it("应传递正确的 code(去除元数据头)和 grants", async () => { + const sender = createMockSender(); + const record: SkillScriptRecord = { + id: "test-uuid-weather", + name: "weather", + description: "查天气", + params: [], + grants: ["GM.xmlHttpRequest"], + code: `// ==SkillScript== +// @name weather +// ==/SkillScript== +const result = await GM.xmlHttpRequest({url: "http://example.com"}); +return result;`, + installtime: 1, + updatetime: 1, + }; + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({}); + + const params = getCallParams(sender); + expect(params.grants).toEqual(["GM.xmlHttpRequest"]); + expect(params.name).toBe("weather"); + expect(params.code).not.toContain("==SkillScript=="); + }); + + it("应处理多个混合类型参数", async () => { + const sender = createMockSender(); + const record = createRecord([ + { name: "city", type: "string", required: true, description: "城市" }, + { name: "days", type: "number", required: false, description: "天数" }, + { name: "detailed", type: "boolean", required: false, description: "详细" }, + ]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ city: "上海", days: "7", detailed: "true" }); + + expect(getCallParams(sender).args).toEqual({ city: "上海", days: 7, detailed: true }); + }); + + it("应生成 skillscript- 前缀的 UUID", async () => { + const sender = createMockSender(); + const record = createRecord(); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({}); + + const params = getCallParams(sender); + expect(params.uuid).toMatch(/^skillscript-/); + expect(params.uuid.length).toBeGreaterThan(SKILL_SCRIPT_UUID_PREFIX.length); + }); + + it("每次执行应生成不同的 UUID", async () => { + const sender = createMockSender(); + const record = createRecord(); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({}); + const uuid1 = getCallParams(sender).uuid; + + sender.sendMessage.mockClear(); + sender.sendMessage.mockResolvedValue({ data: "mock_result" }); + await executor.execute({}); + const uuid2 = getCallParams(sender).uuid; + + expect(uuid1).not.toBe(uuid2); + }); + + it("执行期间应能通过 UUID 查找工具名,执行后应清理映射", async () => { + let capturedUuid = ""; + const sender = { + sendMessage: vi.fn().mockImplementation((msg: any) => { + capturedUuid = msg.data.uuid; + // 执行期间映射应存在 + expect(getSkillScriptNameByUuid(msg.data.uuid)).toBe("test_tool"); + return Promise.resolve({ data: "result" }); + }), + } as any; + + const record = createRecord(); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({}); + + // 执行完成后映射应被清理 + expect(capturedUuid).toBeTruthy(); + expect(getSkillScriptNameByUuid(capturedUuid)).toBe(""); + }); + + it("执行失败时也应清理 UUID 映射", async () => { + let capturedUuid = ""; + const sender = { + sendMessage: vi.fn().mockImplementation((msg: any) => { + capturedUuid = msg.data.uuid; + return Promise.reject(new Error("执行失败")); + }), + } as any; + + const record = createRecord(); + const executor = new SkillScriptExecutor(record, sender); + + await expect(executor.execute({})).rejects.toThrow("执行失败"); + + // 即使失败,映射也应被清理 + expect(capturedUuid).toBeTruthy(); + expect(getSkillScriptNameByUuid(capturedUuid)).toBe(""); + }); +}); + +describe("getSkillScriptNameByUuid", () => { + it("未注册的 UUID 应返回空字符串", () => { + expect(getSkillScriptNameByUuid("skillscript-unknown-uuid")).toBe(""); + }); + + it("空字符串应返回空字符串", () => { + expect(getSkillScriptNameByUuid("")).toBe(""); + }); +}); + +describe("getSkillScriptGrantsByUuid", () => { + it("未注册的 UUID 应返回空数组", () => { + expect(getSkillScriptGrantsByUuid("skillscript-unknown-uuid")).toEqual([]); + }); + + it("执行期间应能通过 UUID 获取 grants", async () => { + let capturedUuid = ""; + const sender = { + sendMessage: vi.fn().mockImplementation((msg: any) => { + capturedUuid = msg.data.uuid; + // 执行期间应能获取 grants + expect(getSkillScriptGrantsByUuid(msg.data.uuid)).toEqual(["CAT.agent.dom", "GM.xmlHttpRequest"]); + return Promise.resolve({ data: "result" }); + }), + } as any; + + const record = createRecord([], { + grants: ["CAT.agent.dom", "GM.xmlHttpRequest"], + }); + const executor = new SkillScriptExecutor(record, sender); + await executor.execute({}); + + // 执行完成后应清理 + expect(getSkillScriptGrantsByUuid(capturedUuid)).toEqual([]); + }); + + it("执行失败时也应清理 grants 映射", async () => { + let capturedUuid = ""; + const sender = { + sendMessage: vi.fn().mockImplementation((msg: any) => { + capturedUuid = msg.data.uuid; + return Promise.reject(new Error("执行失败")); + }), + } as any; + + const record = createRecord([], { grants: ["CAT.agent.dom"] }); + const executor = new SkillScriptExecutor(record, sender); + + await expect(executor.execute({})).rejects.toThrow("执行失败"); + expect(getSkillScriptGrantsByUuid(capturedUuid)).toEqual([]); + }); +}); + +describe("SKILL_SCRIPT_UUID_PREFIX", () => { + it("应为 'skillscript-'", () => { + expect(SKILL_SCRIPT_UUID_PREFIX).toBe("skillscript-"); + }); +}); + +describe("SkillScriptExecutor 类型转换边界值", () => { + it('boolean 转换:"false" → false', async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "flag", type: "boolean", required: false, description: "标记" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ flag: "false" }); + expect(getCallParams(sender).args).toEqual({ flag: false }); + }); + + it('boolean 转换:"0" → false', async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "flag", type: "boolean", required: false, description: "标记" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ flag: "0" }); + expect(getCallParams(sender).args).toEqual({ flag: false }); + }); + + it("boolean 转换:null → false", async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "flag", type: "boolean", required: false, description: "标记" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ flag: null }); + expect(getCallParams(sender).args).toEqual({ flag: false }); + }); + + it('boolean 转换:"true" → true(确认只有这个值和 true 为 true)', async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "flag", type: "boolean", required: false, description: "标记" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ flag: "true" }); + expect(getCallParams(sender).args).toEqual({ flag: true }); + }); + + it('number 转换:"abc" → NaN', async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "count", type: "number", required: false, description: "数量" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ count: "abc" }); + expect(getCallParams(sender).args.count).toBeNaN(); + }); + + it('number 转换:"" → 0', async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "count", type: "number", required: false, description: "数量" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ count: "" }); + expect(getCallParams(sender).args).toEqual({ count: 0 }); + }); + + it("number 转换:null → 0", async () => { + const sender = createMockSender(); + const record = createRecord([{ name: "count", type: "number", required: false, description: "数量" }]); + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ count: null }); + expect(getCallParams(sender).args).toEqual({ count: 0 }); + }); + + it("空 params 定义但有多余 args:只传 metadata 中定义的参数", async () => { + const sender = createMockSender(); + const record = createRecord([]); // 空 params + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({ extra1: "a", extra2: 123, extra3: true }); + expect(getCallParams(sender).args).toEqual({}); + }); +}); + +describe("SkillScriptExecutor @require 加载", () => { + it("有 requires 和 requireLoader 时应加载资源并传给 executeSkillScript", async () => { + const sender = createMockSender(); + const record = createRecord([], { + 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 = {};"); + if (url.includes("lib2")) return Promise.resolve("var LIB2 = {};"); + return Promise.resolve(undefined); + }); + const executor = new SkillScriptExecutor(record, sender, loader); + + await executor.execute({}); + + // requireLoader 应被调用两次 + expect(loader).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenCalledWith("https://cdn.example.com/lib1.js"); + expect(loader).toHaveBeenCalledWith("https://cdn.example.com/lib2.js"); + + // 传给 offscreen 的 params 应包含 requires + const params = getCallParams(sender); + expect(params.requires).toEqual([ + { url: "https://cdn.example.com/lib1.js", content: "var LIB1 = {};" }, + { url: "https://cdn.example.com/lib2.js", content: "var LIB2 = {};" }, + ]); + }); + + it("无 requireLoader 时 requires 不应传给 executeSkillScript", async () => { + const sender = createMockSender(); + const record = createRecord([], { + requires: ["https://cdn.example.com/lib.js"], + }); + // 不传 requireLoader + const executor = new SkillScriptExecutor(record, sender); + + await executor.execute({}); + + const params = getCallParams(sender); + expect(params.requires).toBeUndefined(); + }); + + it("无 requires 字段时不应调用 requireLoader", async () => { + const sender = createMockSender(); + const record = createRecord(); // 默认无 requires + const loader: RequireLoader = vi.fn(); + const executor = new SkillScriptExecutor(record, sender, loader); + + await executor.execute({}); + + expect(loader).not.toHaveBeenCalled(); + const params = getCallParams(sender); + expect(params.requires).toBeUndefined(); + }); + + it("空 requires 数组时不应调用 requireLoader", async () => { + const sender = createMockSender(); + const record = createRecord([], { requires: [] }); + const loader: RequireLoader = vi.fn(); + const executor = new SkillScriptExecutor(record, sender, loader); + + await executor.execute({}); + + expect(loader).not.toHaveBeenCalled(); + const params = getCallParams(sender); + expect(params.requires).toBeUndefined(); + }); + + it("requireLoader 返回 undefined 的 URL 应被跳过", async () => { + const sender = createMockSender(); + const record = createRecord([], { + requires: [ + "https://cdn.example.com/good.js", + "https://cdn.example.com/missing.js", + "https://cdn.example.com/also-good.js", + ], + }); + const loader: RequireLoader = vi.fn().mockImplementation((url: string) => { + if (url.includes("missing")) return Promise.resolve(undefined); + if (url.includes("also-good")) return Promise.resolve("var ALSO = 2;"); + if (url.includes("good")) return Promise.resolve("var GOOD = 1;"); + return Promise.resolve(undefined); + }); + const executor = new SkillScriptExecutor(record, sender, loader); + + await executor.execute({}); + + // loader 被调用 3 次 + expect(loader).toHaveBeenCalledTimes(3); + // 只有成功加载的 2 个资源被传递 + const params = getCallParams(sender); + expect(params.requires).toEqual([ + { url: "https://cdn.example.com/good.js", content: "var GOOD = 1;" }, + { url: "https://cdn.example.com/also-good.js", content: "var ALSO = 2;" }, + ]); + }); + + it("所有 requireLoader 返回 undefined 时 requires 应为 undefined", async () => { + const sender = createMockSender(); + const record = createRecord([], { + requires: ["https://cdn.example.com/missing1.js", "https://cdn.example.com/missing2.js"], + }); + const loader: RequireLoader = vi.fn().mockResolvedValue(undefined); + const executor = new SkillScriptExecutor(record, sender, loader); + + await executor.execute({}); + + const params = getCallParams(sender); + expect(params.requires).toBeUndefined(); + }); +}); + +describe("SkillScriptExecutor 超时处理", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("执行超过默认超时(300s)时应抛出带 errorCode=tool_timeout 的错误", async () => { + vi.useFakeTimers(); + + // sender.sendMessage 永不 resolve,模拟挂死的 SkillScript + const sender = { + sendMessage: vi.fn().mockReturnValue(new Promise(() => {})), + } as any; + + const record = createRecord([], { name: "hang_tool" }); + const executor = new SkillScriptExecutor(record, sender); + + // 先附加 catch 再推进时间,防止 rejection 在处理前被标记为 unhandled + const errPromise = executor.execute({}).catch((e) => e); + + // 推进 300s 触发超时 + await vi.advanceTimersByTimeAsync(300_000); + + const err = await errPromise; + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("hang_tool"); + expect((err as Error).message).toContain("timed out"); + expect((err as any).errorCode).toBe("tool_timeout"); + }); + + it("超时后 UUID 映射应被清理", async () => { + vi.useFakeTimers(); + + let capturedUuid = ""; + const sender = { + sendMessage: vi.fn().mockImplementation((msg: any) => { + capturedUuid = msg.data.uuid; + return new Promise(() => {}); + }), + } as any; + + const record = createRecord([], { name: "hang_tool2" }); + const executor = new SkillScriptExecutor(record, sender); + + const execPromise = executor.execute({}).catch(() => {}); + await vi.advanceTimersByTimeAsync(300_000); + await execPromise; + + expect(capturedUuid).toMatch(/^skillscript-/); + expect(getSkillScriptNameByUuid(capturedUuid)).toBe(""); + }); + + it("自定义 timeout 应覆盖默认 300s", async () => { + vi.useFakeTimers(); + + const sender = { + sendMessage: vi.fn().mockReturnValue(new Promise(() => {})), + } as any; + + const record = createRecord([], { name: "slow_tool", timeout: 120 }); + const executor = new SkillScriptExecutor(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("默认超时内完成的执行不应超时", async () => { + vi.useFakeTimers(); + + const sender = { + sendMessage: vi.fn().mockResolvedValue({ data: "ok" }), + } as any; + + const record = createRecord(); + const executor = new SkillScriptExecutor(record, sender); + + const execPromise = executor.execute({}); + // 推进 5s,执行早已完成(mock 是 resolved) + await vi.advanceTimersByTimeAsync(5_000); + + await expect(execPromise).resolves.toBeDefined(); + }); +}); diff --git a/src/app/service/agent/core/skill_script_executor.ts b/src/app/service/agent/core/skill_script_executor.ts new file mode 100644 index 000000000..ff8f332de --- /dev/null +++ b/src/app/service/agent/core/skill_script_executor.ts @@ -0,0 +1,103 @@ +import type { MessageSend } from "@Packages/message/types"; +import type { SkillScriptRecord, JsonValue } from "./types"; +import type { ToolExecutor } from "./tool_registry"; +import { getSkillScriptBody } from "@App/pkg/utils/skill_script"; +import { executeSkillScript } from "@App/app/service/offscreen/client"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { withTimeout } from "@App/pkg/utils/with_timeout"; + +// Skill Script UUID 前缀,用于在 GM API 请求中识别 Skill Script +export const SKILL_SCRIPT_UUID_PREFIX = "skillscript-"; + +// Skill Script 默认超时(ms) +const SKILL_SCRIPT_DEFAULT_TIMEOUT_MS = 300_000; + +// 全局的 Skill Script UUID → 工具信息映射,供 GM API 权限验证时使用 +// 直接携带 grants,避免运行时再查 repo(skill 的 Skill Script 不在 skillScriptRepo 中) +// 注意:此 Map 在 SW 重启后会丢失,但 Skill Script 执行是单次 request-response, +// SW 重启会同时中断消息通道,所以映射丢失不会导致额外问题 +const skillScriptUuidMap = new Map(); + +// 根据 Skill Script UUID 获取工具名 +export function getSkillScriptNameByUuid(uuid: string): string { + return skillScriptUuidMap.get(uuid)?.name || ""; +} + +// 根据 Skill Script UUID 直接获取 grants(用于 GM API 权限验证) +export function getSkillScriptGrantsByUuid(uuid: string): string[] { + return skillScriptUuidMap.get(uuid)?.grants || []; +} + +// require 资源加载器类型:根据 URL 返回资源内容 +export type RequireLoader = (url: string) => Promise; + +// Skill Script 执行器,通过 Offscreen -> Sandbox 执行 Skill Script 脚本 +export class SkillScriptExecutor implements ToolExecutor { + constructor( + private record: SkillScriptRecord, + private sender: MessageSend, + private requireLoader?: RequireLoader, + private configValues?: Record + ) {} + + async execute(args: Record): Promise { + // 根据 @param 定义做基本的类型转换 + const typedArgs: Record = {}; + for (const param of this.record.params) { + const val = args[param.name]; + if (val === undefined) continue; + switch (param.type) { + case "number": + typedArgs[param.name] = Number(val); + break; + case "boolean": + typedArgs[param.name] = val === true || val === "true"; + break; + default: + typedArgs[param.name] = String(val); + } + } + + // 在 service worker 端生成 UUID 并注册映射 + const uuid = SKILL_SCRIPT_UUID_PREFIX + uuidv4(); + skillScriptUuidMap.set(uuid, { name: this.record.name, grants: this.record.grants }); + + // 加载 @require 资源内容 + let requires: Array<{ url: string; content: string }> | undefined; + if (this.record.requires?.length && this.requireLoader) { + const loaded: Array<{ url: string; content: string }> = []; + for (const url of this.record.requires) { + const content = await this.requireLoader(url); + if (content) { + loaded.push({ url, content }); + } + } + if (loaded.length > 0) { + requires = loaded; + } + } + + const code = getSkillScriptBody(this.record.code); + const timeoutMs = this.record.timeout ? this.record.timeout * 1000 : SKILL_SCRIPT_DEFAULT_TIMEOUT_MS; + const timeoutSec = timeoutMs / 1000; + try { + const execPromise = executeSkillScript(this.sender, { + uuid, + code, + args: typedArgs, + grants: this.record.grants, + name: this.record.name, + requires, + configValues: this.configValues, + }); + return await withTimeout(execPromise, timeoutMs, () => + Object.assign(new Error(`SkillScript "${this.record.name}" timed out after ${timeoutSec}s`), { + errorCode: "tool_timeout", + }) + ); + } finally { + // 执行完毕后清理映射 + skillScriptUuidMap.delete(uuid); + } + } +} diff --git a/src/app/service/agent/core/sse_parser.test.ts b/src/app/service/agent/core/sse_parser.test.ts new file mode 100644 index 000000000..6e8ddc845 --- /dev/null +++ b/src/app/service/agent/core/sse_parser.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { SSEParser } from "./sse_parser"; + +describe("SSEParser", () => { + it("应正确解析单个 SSE 事件", () => { + const parser = new SSEParser(); + const events = parser.parse('data: {"text":"hello"}\n\n'); + expect(events).toHaveLength(1); + expect(events[0].event).toBe("message"); + expect(events[0].data).toBe('{"text":"hello"}'); + }); + + it("应正确解析带 event 字段的事件", () => { + const parser = new SSEParser(); + const events = parser.parse('event: content_block_delta\ndata: {"delta":"hi"}\n\n'); + expect(events).toHaveLength(1); + expect(events[0].event).toBe("content_block_delta"); + expect(events[0].data).toBe('{"delta":"hi"}'); + }); + + it("应正确处理跨 chunk 的事件", () => { + const parser = new SSEParser(); + const events1 = parser.parse('data: {"text":'); + expect(events1).toHaveLength(0); + const events2 = parser.parse('"hello"}\n\n'); + expect(events2).toHaveLength(1); + expect(events2[0].data).toBe('{"text":"hello"}'); + }); + + it("应正确解析多个连续事件", () => { + const parser = new SSEParser(); + const events = parser.parse('data: {"a":1}\n\ndata: {"b":2}\n\n'); + expect(events).toHaveLength(2); + expect(events[0].data).toBe('{"a":1}'); + expect(events[1].data).toBe('{"b":2}'); + }); + + it("应忽略注释行", () => { + const parser = new SSEParser(); + const events = parser.parse(": comment\ndata: test\n\n"); + expect(events).toHaveLength(1); + expect(events[0].data).toBe("test"); + }); + + it("应正确处理 \\r\\n 换行符", () => { + const parser = new SSEParser(); + const events = parser.parse("data: hello\r\n\r\n"); + expect(events).toHaveLength(1); + expect(events[0].data).toBe("hello"); + }); + + it("应正确处理多行 data 字段(拼接为换行符分隔)", () => { + const parser = new SSEParser(); + const events = parser.parse("data: line1\ndata: line2\ndata: line3\n\n"); + expect(events).toHaveLength(1); + expect(events[0].data).toBe("line1\nline2\nline3"); + }); + + it("应忽略没有冒号的行", () => { + const parser = new SSEParser(); + const events = parser.parse("invalid-line\ndata: valid\n\n"); + expect(events).toHaveLength(1); + expect(events[0].data).toBe("valid"); + }); + + it("空行但无 data 时不应产生事件", () => { + const parser = new SSEParser(); + const events = parser.parse("\n\n"); + expect(events).toHaveLength(0); + }); + + it("data 冒号后无空格时应正确解析", () => { + const parser = new SSEParser(); + const events = parser.parse("data:no-space\n\n"); + expect(events).toHaveLength(1); + expect(events[0].data).toBe("no-space"); + }); + + it("data 为空字符串时应正确解析", () => { + const parser = new SSEParser(); + const events = parser.parse("data: \n\n"); + expect(events).toHaveLength(1); + expect(events[0].data).toBe(""); + }); + + it("reset 后应清除所有状态", () => { + const parser = new SSEParser(); + // 先输入一个不完整的事件 + parser.parse("data: partial"); + parser.reset(); + // reset 后新的输入应从头开始 + const events = parser.parse("data: fresh\n\n"); + expect(events).toHaveLength(1); + expect(events[0].data).toBe("fresh"); + }); + + it("reset 后之前的 event 字段不应残留", () => { + const parser = new SSEParser(); + parser.parse("event: old_type\n"); + parser.reset(); + const events = parser.parse("data: new\n\n"); + expect(events).toHaveLength(1); + expect(events[0].event).toBe("message"); // 不应是 old_type + }); + + it("应处理跨多个 chunk 的复杂场景", () => { + const parser = new SSEParser(); + // chunk1: event 和部分 data + const e1 = parser.parse("event: test\ndata: part"); + expect(e1).toHaveLength(0); + // chunk2: data 剩余部分和空行 + const e2 = parser.parse("ial\n\n"); + expect(e2).toHaveLength(1); + expect(e2[0].event).toBe("test"); + expect(e2[0].data).toBe("partial"); + }); + + it("连续空行不应产生重复事件", () => { + const parser = new SSEParser(); + const events = parser.parse("data: once\n\n\n\n"); + expect(events).toHaveLength(1); + }); + + it("应忽略未知字段", () => { + const parser = new SSEParser(); + const events = parser.parse("id: 123\nretry: 5000\ndata: hello\n\n"); + expect(events).toHaveLength(1); + expect(events[0].data).toBe("hello"); + }); +}); diff --git a/src/app/service/agent/core/sse_parser.ts b/src/app/service/agent/core/sse_parser.ts new file mode 100644 index 000000000..62dfd30ad --- /dev/null +++ b/src/app/service/agent/core/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/core/sub_agent_types.test.ts b/src/app/service/agent/core/sub_agent_types.test.ts new file mode 100644 index 000000000..2b9e0db04 --- /dev/null +++ b/src/app/service/agent/core/sub_agent_types.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { resolveSubAgentType, getExcludeToolsForType, SUB_AGENT_TYPES } from "./sub_agent_types"; + +describe("Sub-Agent 类型系统", () => { + describe("resolveSubAgentType", () => { + it.concurrent("返回指定的内置类型", () => { + expect(resolveSubAgentType("researcher")).toBe(SUB_AGENT_TYPES.researcher); + expect(resolveSubAgentType("page_operator")).toBe(SUB_AGENT_TYPES.page_operator); + expect(resolveSubAgentType("general")).toBe(SUB_AGENT_TYPES.general); + }); + + it.concurrent("未知类型 fallback 到 general", () => { + expect(resolveSubAgentType("unknown_type")).toBe(SUB_AGENT_TYPES.general); + expect(resolveSubAgentType("")).toBe(SUB_AGENT_TYPES.general); + }); + + it.concurrent("undefined/不传参数返回 general", () => { + expect(resolveSubAgentType()).toBe(SUB_AGENT_TYPES.general); + expect(resolveSubAgentType(undefined)).toBe(SUB_AGENT_TYPES.general); + }); + }); + + describe("getExcludeToolsForType", () => { + const allTools = [ + "web_fetch", + "web_search", + "opfs_read", + "opfs_write", + "opfs_list", + "opfs_delete", + "execute_script", + "get_tab_content", + "list_tabs", + "open_tab", + "close_tab", + "activate_tab", + "ask_user", + "agent", + "create_task", + "update_task", + "get_task", + "list_tasks", + "delete_task", + ]; + + it.concurrent("researcher 类型排除 tab 工具和其他不在白名单中的工具", () => { + const config = SUB_AGENT_TYPES.researcher; + const excluded = getExcludeToolsForType(config, allTools); + + // researcher 不包含 tab 工具、ask_user、agent + expect(excluded).toContain("get_tab_content"); + expect(excluded).toContain("list_tabs"); + expect(excluded).toContain("open_tab"); + expect(excluded).toContain("close_tab"); + expect(excluded).toContain("activate_tab"); + expect(excluded).toContain("ask_user"); + expect(excluded).toContain("agent"); + + // task 工具始终可用(ALWAYS_ALLOWED_TOOLS) + expect(excluded).not.toContain("create_task"); + expect(excluded).not.toContain("update_task"); + expect(excluded).not.toContain("list_tasks"); + + // 应该保留的工具不在排除列表中 + expect(excluded).not.toContain("web_fetch"); + expect(excluded).not.toContain("web_search"); + expect(excluded).not.toContain("execute_script"); + expect(excluded).not.toContain("opfs_read"); + }); + + it.concurrent("page_operator 类型排除 web_search 和其他不在白名单中的工具", () => { + const config = SUB_AGENT_TYPES.page_operator; + const excluded = getExcludeToolsForType(config, allTools); + + // page_operator 不包含 web_search、ask_user、agent + expect(excluded).toContain("web_search"); + expect(excluded).toContain("ask_user"); + expect(excluded).toContain("agent"); + + // 应该保留 tab 工具 + expect(excluded).not.toContain("get_tab_content"); + expect(excluded).not.toContain("list_tabs"); + expect(excluded).not.toContain("open_tab"); + expect(excluded).not.toContain("execute_script"); + expect(excluded).not.toContain("web_fetch"); + + // task 工具始终可用 + expect(excluded).not.toContain("create_task"); + expect(excluded).not.toContain("update_task"); + }); + + it.concurrent("general 类型使用黑名单模式,只排除 ask_user 和 agent", () => { + const config = SUB_AGENT_TYPES.general; + const excluded = getExcludeToolsForType(config, allTools); + + expect(excluded).toEqual(["ask_user", "agent"]); + }); + + it.concurrent("allowedTools 和 excludeTools 都未指定时返回空数组", () => { + const config: any = { name: "empty", maxIterations: 10, timeoutMs: 60000, systemPromptAddition: "" }; + const excluded = getExcludeToolsForType(config, allTools); + expect(excluded).toEqual([]); + }); + + it.concurrent("allowedTools 优先于 excludeTools", () => { + const config: any = { + name: "test", + allowedTools: ["web_fetch"], + excludeTools: ["web_search"], + maxIterations: 10, + timeoutMs: 60000, + systemPromptAddition: "", + }; + const excluded = getExcludeToolsForType(config, ["web_fetch", "web_search", "execute_script"]); + + // 使用白名单模式,排除不在 allowedTools 中的 + expect(excluded).toContain("web_search"); + expect(excluded).toContain("execute_script"); + expect(excluded).not.toContain("web_fetch"); + }); + }); +}); diff --git a/src/app/service/agent/core/sub_agent_types.ts b/src/app/service/agent/core/sub_agent_types.ts new file mode 100644 index 000000000..65ceb83fa --- /dev/null +++ b/src/app/service/agent/core/sub_agent_types.ts @@ -0,0 +1,108 @@ +// 子代理类型定义和注册表 + +export interface SubAgentTypeConfig { + name: string; + description: string; // 英文,写入 agent tool 描述供 LLM 选择 + allowedTools?: string[]; // 白名单模式(优先于 excludeTools) + excludeTools?: string[]; // 黑名单模式 + maxIterations: number; + timeoutMs: number; + systemPromptAddition: string; // 注入 sub-agent system prompt 的角色说明 +} + +// 所有子代理类型都默认可用的工具(task 工具用于与主 agent 共享任务进度) +const ALWAYS_ALLOWED_TOOLS = ["create_task", "update_task", "get_task", "list_tasks", "delete_task"]; + +// 内置子代理类型 +export const SUB_AGENT_TYPES: Record = { + researcher: { + name: "researcher", + description: "Web search/fetch, data analysis, no tab interaction", + allowedTools: ["web_fetch", "web_search", "opfs_read", "opfs_write", "opfs_list", "opfs_delete", "execute_script"], + maxIterations: 20, + timeoutMs: 600_000, + systemPromptAddition: `## Role: Researcher + +You are a research-focused sub-agent. Your job is to search, fetch, read, and summarize information. + +**Capabilities:** Web search, URL fetching, data analysis via execute_script (sandbox mode only). +**Limitations:** You cannot interact with browser tabs (no navigation, clicking, or form filling). You cannot ask the user questions. + +**Guidelines:** +- Use web_search to find relevant sources, then web_fetch to read them. +- Synthesize information from multiple sources when possible. +- Return structured, concise results that the parent agent can act on. +- If you cannot find the information, say so clearly rather than guessing.`, + }, + + page_operator: { + name: "page_operator", + description: "Browser tab interaction, page automation", + allowedTools: [ + "get_tab_content", + "list_tabs", + "open_tab", + "close_tab", + "activate_tab", + "execute_script", + "web_fetch", + "opfs_read", + "opfs_write", + "opfs_list", + "opfs_delete", + ], + maxIterations: 30, + timeoutMs: 600_000, + systemPromptAddition: `## Role: Page Operator + +You are a page interaction sub-agent. Your job is to navigate web pages, interact with elements, and extract data. + +**Capabilities:** Tab navigation, page reading, DOM interaction via execute_script, URL fetching. +**Limitations:** You cannot search the web (use a researcher sub-agent for that). You cannot ask the user questions. + +**Guidelines:** +- Always read the page content (get_tab_content) before interacting to understand the current state. +- Verify page state after each interaction — never assume an action succeeded. +- For form filling, check that inputs exist and are visible before attempting to fill them. +- Return extracted data in a structured format.`, + }, + + general: { + name: "general", + description: "All tools, general-purpose", + excludeTools: ["ask_user", "agent"], + maxIterations: 30, + timeoutMs: 600_000, + systemPromptAddition: `## Role: General Sub-Agent + +You are a general-purpose sub-agent with access to all tools except user interaction and nested sub-agents. + +**Limitations:** You cannot ask the user questions and cannot spawn nested sub-agents. If you encounter a situation that requires user input, describe the situation clearly in your response so the parent agent can handle it.`, + }, +}; + +/** + * 解析子代理类型名称为配置,未知类型 fallback 到 general + */ +export function resolveSubAgentType(typeName?: string): SubAgentTypeConfig { + if (!typeName) return SUB_AGENT_TYPES.general; + return SUB_AGENT_TYPES[typeName] || SUB_AGENT_TYPES.general; +} + +/** + * 根据类型配置和所有可用工具名,计算最终的排除工具列表 + * - 白名单模式:排除不在 allowedTools 中的工具 + * - 黑名单模式:直接使用 excludeTools + * - 两者都未指定:返回空数组(不排除任何工具) + */ +export function getExcludeToolsForType(config: SubAgentTypeConfig, allToolNames: string[]): string[] { + if (config.allowedTools && config.allowedTools.length > 0) { + // 白名单模式:合并 allowedTools + ALWAYS_ALLOWED_TOOLS + const allowedSet = new Set([...config.allowedTools, ...ALWAYS_ALLOWED_TOOLS]); + return allToolNames.filter((name) => !allowedSet.has(name)); + } + if (config.excludeTools && config.excludeTools.length > 0) { + return [...config.excludeTools]; + } + return []; +} diff --git a/src/app/service/agent/core/system_prompt.test.ts b/src/app/service/agent/core/system_prompt.test.ts new file mode 100644 index 000000000..9f94d6ef2 --- /dev/null +++ b/src/app/service/agent/core/system_prompt.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; + +import { buildSystemPrompt, buildSubAgentSystemPrompt, _BUILTIN_SYSTEM_PROMPT_FOR_TEST } from "./system_prompt"; +import { SUB_AGENT_TYPES } from "./sub_agent_types"; + +describe("buildSystemPrompt", () => { + it("无 userSystem、无 skillSuffix 时只返回内置提示词", () => { + const result = buildSystemPrompt({}); + expect(result).toContain("You are ScriptCat Agent"); + expect(result).toContain("## Core Principles"); + // 末尾不应有多余的空行 + expect(result.endsWith("\n\n")).toBe(false); + }); + + it("输出与导出的 BUILTIN_SYSTEM_PROMPT 一致", () => { + const result = buildSystemPrompt({}); + // 分段组装后应与合并后的常量一致 + expect(result).toBe(_BUILTIN_SYSTEM_PROMPT_FOR_TEST); + }); + + it("包含所有主要段落标题", () => { + const result = buildSystemPrompt({}); + expect(result).toContain("## Core Principles"); + expect(result).toContain("## Planning"); + expect(result).toContain("## Tool Usage"); + expect(result).toContain("## Safety"); + expect(result).toContain("## Communication"); + expect(result).toContain("## Tool Selection Guide"); + expect(result).toContain("## Sub-Agent"); + expect(result).toContain("## Task Management"); + expect(result).toContain("## OPFS Workspace"); + }); + + it("Planning 段包含研究先于实施原则", () => { + const result = buildSystemPrompt({}); + expect(result).toContain("Research before action"); + }); + + it("Communication 段包含输出效率指导", () => { + const result = buildSystemPrompt({}); + expect(result).toContain("Lead with action, not reasoning"); + }); + + it("Sub-Agent 段包含提示词写作指南和反模式", () => { + const result = buildSystemPrompt({}); + expect(result).toContain("### Writing Sub-Agent Prompts"); + expect(result).toContain("Never delegate understanding"); + expect(result).toContain("### Anti-Patterns"); + expect(result).toContain("Don't predict sub-agent results"); + expect(result).toContain("Don't duplicate work"); + }); + + it("有 userSystem 时拼接在内置提示词之后", () => { + const result = buildSystemPrompt({ userSystem: "You are a helpful bot." }); + expect(result).toContain("You are ScriptCat Agent"); + expect(result).toContain("You are a helpful bot."); + }); + + it("有 skillSuffix 时拼接在末尾", () => { + const result = buildSystemPrompt({ + skillSuffix: "\n# Available Skills\n- browser_automation", + }); + expect(result).toContain("You are ScriptCat Agent"); + expect(result).toContain("# Available Skills"); + }); + + it("都有时按顺序拼接:内置 + userSystem + skillSuffix", () => { + const result = buildSystemPrompt({ + userSystem: "Custom instructions here.", + skillSuffix: "\n# Skills\n- test_skill", + }); + + const builtinPos = result.indexOf("You are ScriptCat Agent"); + const userPos = result.indexOf("Custom instructions here."); + const skillPos = result.indexOf("# Skills"); + + expect(builtinPos).toBeLessThan(userPos); + expect(userPos).toBeLessThan(skillPos); + }); + + it("userSystem 为空字符串时不额外追加", () => { + const result = buildSystemPrompt({ userSystem: "" }); + // 不应出现连续三个换行(即空段落) + expect(result).not.toContain("\n\n\n"); + }); +}); + +describe("buildSubAgentSystemPrompt", () => { + const allTools = [ + "web_fetch", + "web_search", + "opfs_read", + "opfs_write", + "opfs_list", + "opfs_delete", + "execute_script", + "get_tab_content", + "list_tabs", + "open_tab", + "close_tab", + "activate_tab", + ]; + + it.concurrent("researcher 类型不包含 Sub-Agent 段", () => { + const config = SUB_AGENT_TYPES.researcher; + const tools = config.allowedTools || []; + const result = buildSubAgentSystemPrompt(config, tools); + + expect(result).not.toContain("## Sub-Agent"); + }); + + it.concurrent("researcher 类型不包含 tab 工具的描述", () => { + const config = SUB_AGENT_TYPES.researcher; + const tools = config.allowedTools || []; + const result = buildSubAgentSystemPrompt(config, tools); + + expect(result).not.toContain("get_tab_content"); + expect(result).toContain("web_fetch"); + expect(result).toContain("web_search"); + }); + + it.concurrent("researcher 类型包含角色说明", () => { + const config = SUB_AGENT_TYPES.researcher; + const tools = config.allowedTools || []; + const result = buildSubAgentSystemPrompt(config, tools); + + expect(result).toContain("## Role: Researcher"); + }); + + it.concurrent("page_operator 类型包含 tab 工具描述,不包含 web_search", () => { + const config = SUB_AGENT_TYPES.page_operator; + const tools = config.allowedTools || []; + const result = buildSubAgentSystemPrompt(config, tools); + + expect(result).toContain("get_tab_content"); + expect(result).not.toContain("web_search"); + expect(result).toContain("## Role: Page Operator"); + }); + + it.concurrent("general 类型包含所有工具描述", () => { + const config = SUB_AGENT_TYPES.general; + const result = buildSubAgentSystemPrompt(config, allTools); + + expect(result).toContain("get_tab_content"); + expect(result).toContain("web_fetch"); + expect(result).toContain("web_search"); + }); + + it.concurrent("不包含 ask_user 引用", () => { + const config = SUB_AGENT_TYPES.general; + const result = buildSubAgentSystemPrompt(config, allTools); + + expect(result).not.toContain("ask_user"); + }); + + it.concurrent("子代理包含结构化输出格式指南", () => { + const config = SUB_AGENT_TYPES.general; + const result = buildSubAgentSystemPrompt(config, allTools); + + expect(result).toContain("**Result**"); + expect(result).toContain("**Data**"); + expect(result).toContain("**Issues**"); + }); + + it.concurrent("子代理开头为 sub-agent 角色描述", () => { + const config = SUB_AGENT_TYPES.general; + const result = buildSubAgentSystemPrompt(config, allTools); + + expect(result).toMatch(/^You are a ScriptCat sub-agent/); + }); + + it.concurrent("无 OPFS 工具时不包含 OPFS 段", () => { + const config = SUB_AGENT_TYPES.researcher; + const tools = ["web_fetch", "web_search", "execute_script"]; + const result = buildSubAgentSystemPrompt(config, tools); + + expect(result).not.toContain("## OPFS Workspace"); + }); + + it.concurrent("有 OPFS 工具时包含 OPFS 段", () => { + const config = SUB_AGENT_TYPES.researcher; + const tools = config.allowedTools || []; + const result = buildSubAgentSystemPrompt(config, tools); + + expect(result).toContain("## OPFS Workspace"); + }); +}); diff --git a/src/app/service/agent/core/system_prompt.ts b/src/app/service/agent/core/system_prompt.ts new file mode 100644 index 000000000..100d5f54d --- /dev/null +++ b/src/app/service/agent/core/system_prompt.ts @@ -0,0 +1,389 @@ +// Agent 内置系统提示词(分段组装) + +import type { SubAgentTypeConfig } from "./sub_agent_types"; + +// ===================== 主 Agent 系统提示词各段 ===================== + +const SECTION_INTRO = `You are ScriptCat Agent, an AI assistant built into the ScriptCat browser extension. You help users automate browser tasks, extract web data, and manage userscripts.`; + +const SECTION_CORE_PRINCIPLES = `## Core Principles + +- Before interacting with a page, verify its current state — never assume a page is as expected. +- When a step fails, analyze the cause and change your approach. Never retry the exact same action. +- Prefer asking the user over guessing. One good question saves many wasted tool calls.`; + +const SECTION_PLANNING = `## Planning + +- **Simple tasks** (single step, clear intent): act directly with 1-2 tool calls. +- **Complex tasks** (multi-step, involves navigation across pages, form submissions, or data processing): + 1. **Think first** — Analyze the task and design a clear execution plan. + 2. **Propose the plan** — Present a numbered step-by-step plan to the user and wait for confirmation. + 3. **Create tasks** — Use task tools to track each step. + 4. **Delegate steps to sub-agents** — For each independent step, spawn a specialized sub-agent (\`researcher\` for info gathering, \`page_operator\` for page interaction). Launch multiple sub-agents in the same response for parallel execution. You should orchestrate and summarize — not do the work yourself. + 5. **Summarize results** — After sub-agents complete, summarize the results for the user. +- **Your primary role is orchestrator**: plan, delegate, and summarize. Only do work directly when it is truly a 1-step task. If a task involves web searching, page reading, or multi-step page interaction, delegate it to a sub-agent. +- **Research before action** — For unfamiliar sites or complex workflows, first understand the structure (read the page, search for documentation) before attempting interaction. Blind interaction wastes tool calls. +- During execution, if the situation deviates from the plan, **stop and inform the user** with an updated plan rather than silently improvising. +- **Avoid speculative chains** — Do not chain multiple uncertain actions. If the first step's outcome is uncertain, verify before proceeding.`; + +const SECTION_TOOL_USAGE = `## Tool Usage + +You have built-in tools (web_fetch, web_search, tabs, OPFS, execute_script, tasks, ask_user, agent) plus additional tools from Skills and MCP servers. Read each tool's description before calling — it defines behavior, parameters, and constraints. When a tool returns an error, read the error message and adapt — do not blindly retry. + +**Tool call budget**: You have a limited number of tool calls per conversation (typically 50). Use them wisely — plan before acting, combine steps when possible, and stop early if stuck. + +### Page Interaction Workflow + +1. **Discover first** — Call \`get_tab_content\` with a prompt like "find the title input, content editor, and submit button — return their CSS selectors and current state". The response includes \`\` annotations for key elements. +2. **Act with known selectors** — Use the selectors from step 1 in \`execute_script\`. Never guess or hardcode selectors. +3. **Verify with \`execute_script\`** — After an action, check the result with a targeted script (e.g., \`return document.querySelector('#title').value\`). Do NOT call \`get_tab_content\` again just to verify a small action. +4. **Re-read only after major changes** — Only call \`get_tab_content\` again after navigation to a new page or a major DOM change (e.g., a modal appeared). + +### Failure Detection — Stop Early, Ask Early + +**How to judge failure**: Compare the actual outcome against your intent. An \`execute_script\` returning \`null\` may or may not be a failure — judge by whether the intended effect actually happened (e.g., did the field get filled? Did the element appear?). But if you have no way to confirm the effect, treat uncertain results as potential failures. + +**Failure limits — hard rules:** +- **1st failure**: Try ONE different approach (different selector, different method). +- **2nd failure**: **STOP immediately.** Use \`ask_user\` to explain what you tried and ask for help. Do NOT attempt a 3rd approach. +- **Same tool + same arguments**: Never call the exact same thing twice. +- **3+ tool calls without meaningful progress**: Stop and ask the user. + +### Escalation +When stopped due to failures: +1. **Summarize concisely** — tell the user what you tried and what happened. +2. **Suggest next steps** — ask if the user can help (e.g., provide correct selectors, try manually). +3. **Never silently retry** — the user must know when something isn't working. + +**Default to asking**: When in doubt between trying another approach and asking the user, always ask. + +**System guard**: The system automatically detects repetitive tool call patterns and will warn you with a \`[System Warning]\` message. If you receive one, follow its guidance immediately — do not ignore it.`; + +const SECTION_SAFETY = `## Safety + +- **Confirm before irreversible actions**: submitting forms, making purchases, deleting data, posting content. +- **Proceed freely on read-only actions**: navigating, reading content, taking screenshots, extracting data. +- **Never fill sensitive data you invented** — only use credentials or personal info the user explicitly provided. +- **Never bypass site security** — do not attempt to circumvent CAPTCHAs, rate limits, or access controls. If blocked, inform the user. +- If the user's intent is unclear, ask before acting.`; + +const SECTION_COMMUNICATION = `## Communication + +- **Lead with action, not reasoning** — state what you will do, not why you're thinking about it. If you can say it in one sentence, don't use three. +- Focus text output on: status updates at milestones, decisions needing user input, errors or blockers. Skip filler words, preamble, and unnecessary transitions. +- Respond in the user's language. +- When a task is blocked, explain the specific reason and what the user can do about it. +- When reporting extracted data or results, format them clearly (use lists or structured text).`; + +const SECTION_TOOL_GUIDE = `## Tool Selection Guide + +- **Read page content & get selectors** → \`get_tab_content\` returns markdown with CSS selector annotations (\`\`). Always use this first to discover the correct selectors before interacting with the page. +- **Interact with page DOM** → \`execute_script(target='page')\` for clicking, filling forms, reading dynamic state. **Always call \`get_tab_content\` first** to get the correct selectors — never guess selectors. Use the selectors from \`get_tab_content\` annotations in your \`execute_script\` code. +- **Fetch remote data** → \`web_fetch\` for text/HTML/JSON. It does NOT support binary downloads — use a SkillScript with \`fetch()\` + \`CAT.agent.opfs.write(blob)\` for binary files. +- **Compute without DOM** → \`execute_script(target='sandbox')\` for data processing, text parsing, calculations. +- **Search the web** → \`web_search\` returns titles, URLs, and snippets. Follow up with \`web_fetch\` to read specific results. +- **Ask user** → \`ask_user\` to gather preferences, clarify ambiguous instructions, or get decisions on implementation choices. Prefer providing \`options\` for structured choices so the user can select quickly; add \`multiple: true\` for multi-select. If you recommend a specific option, put it first and append "(Recommended)". The user can always type a custom response even when options are provided.`; + +const SECTION_SUB_AGENT = `## Sub-Agent + +**You are an orchestrator. Your default behavior is to delegate work to sub-agents, not to do it yourself.** + +Any task that involves 2+ tool calls (web searching, page reading, page interaction, data processing) MUST be delegated to a sub-agent. You should only call tools directly for truly single-step operations or when you need to ask the user a question. + +### Sub-Agent Types + +- **researcher** — Web search/fetch, data analysis. No tab interaction. Use for: information gathering, comparison research, content summarization, finding URLs/data. +- **page_operator** — Browser tab interaction, page automation. Use for: navigating pages, filling forms, extracting page data, clicking buttons, writing content into editors. +- **general** (default) — All tools. Use when the task spans both research and page interaction. + +### Delegation Examples + +**Example 1: "Write an article about X and publish it on the blog platform"** +1. Spawn \`researcher\` sub-agent → "Research X: find key features, advantages, use cases. Return structured notes." +2. Use the research result to draft the article content yourself (or delegate to another sub-agent). +3. Spawn \`page_operator\` sub-agent → "Open the blog editor, navigate to new post, write this HTML content into the editor: [content]" + +**Example 2: "Compare prices for product X across 3 websites"** +Spawn 3 \`page_operator\` sub-agents in the same response (parallel): +- "Go to site A, find the price of product X, return price and URL" +- "Go to site B, find the price of product X, return price and URL" +- "Go to site C, find the price of product X, return price and URL" +Then summarize results in a comparison table. + +**Example 3: "Fill out the form on this page"** +This is a single-scope page task → spawn one \`page_operator\` sub-agent with the form data. + +### Writing Sub-Agent Prompts + +The sub-agent starts fresh — it has zero context from this conversation. Brief it like a colleague who just walked into the room: +- **Explain the goal and why** — what you're trying to accomplish and what matters. Terse, command-style prompts produce shallow, generic work. +- **Include what you already know** — relevant data, URLs, selectors, constraints. Don't make it re-discover things you already found. +- **Describe what you've ruled out** — so it doesn't repeat failed approaches. +- **Never delegate understanding** — don't write "based on the research, do X". Digest the information yourself first, then write specific instructions with concrete details (file paths, selectors, exact data to fill). + +### Anti-Patterns + +- **Don't predict sub-agent results** — after launching, you know nothing about what it found. If the user asks before results arrive, tell them the sub-agent is still running — give status, not a guess. +- **Don't duplicate work** — if you delegated research to a sub-agent, do not also perform the same searches yourself. +- **Don't chain blindly** — if sub-agent A's result feeds into sub-agent B, wait for A to finish and digest its output before writing B's prompt. + +### Usage Notes + +- **Always include a short description** (3-5 words) summarizing what the sub-agent will do. +- **Launch multiple agents concurrently** whenever possible — call \`agent\` multiple times **in the same response**. +- Sub-agent results are not visible to the user. Summarize the results for the user after sub-agents complete. +- Sub-agents share the parent's task list — they can call \`update_task\` to report progress. +- To continue a previously completed sub-agent, use the \`to\` parameter with the agentId. + +### When NOT to Use + +- Single tool calls (e.g., one \`ask_user\`, one \`web_fetch\` for a quick check). +- Tasks that require user decisions mid-way — sub-agents cannot use \`ask_user\`. + +### Constraints + +Sub-agents cannot ask the user questions, cannot spawn nested sub-agents, and have a 10-minute timeout.`; + +const SECTION_TASK_MANAGEMENT = `## Task Management + +Use task tools **only** when tracking progress genuinely helps the user understand a complex workflow. + +**When to use:** +- The task requires 3+ distinct steps AND benefits from visible progress tracking +- The user provides multiple independent things to do at once + +**When NOT to use:** +- Tasks with 1-2 steps — just execute directly +- Tasks you will complete in the same or next tool call — creating a task just to immediately complete it wastes tool calls +- Tasks already delegated to sub-agents — sub-agents handle their own execution +- Purely conversational or informational requests + +**Workflow:** +1. **Plan** — Call \`list_tasks\` to check for existing tasks, then \`create_task\` for each step with a clear imperative subject and enough description for context. +2. **Execute** — Before starting each task, call \`update_task\` with \`status: "in_progress"\`. When done, set \`status: "completed"\`. +3. **Adapt** — If a completed task reveals follow-up work, create new tasks. If a task becomes irrelevant, use \`delete_task\` to clean up. + +**Important:** Do not create tasks just to log what you already did or are about to do in the same response.`; + +const SECTION_OPFS = `## OPFS Workspace + +OPFS stores files persistently (survives conversation restarts). Supports both **text** and **binary** data. + +**When to use OPFS**: +- Text data that needs to persist across conversations (config, notes, structured data) → \`opfs_write\` to save, \`opfs_read\` to retrieve text content +- Binary files that need to be passed to the page: images, PDFs, downloads → \`opfs_write\` to save, \`opfs_read\` to get blob URL +- SkillScript intermediate output (e.g., generated images saved via \`CAT.agent.opfs.write(blob)\`) + +**When NOT to use OPFS**: +- Text content already in conversation context (tool results, extracted data) — use it directly +- Temporary data only needed within the current conversation — keep in context + +**Text file reading**: \`opfs_read\` detects MIME type automatically. +- Text files (txt, json, js, html, css, xml, etc.) → returns text content directly with line info +- If text exceeds 200 lines, you **MUST** use \`offset\` and \`limit\` to read in segments +- Binary files (images, PDFs, etc.) → returns blob URL + +**Binary file workflow**: +**Save**: screenshot with \`saveTo\` / SkillScript \`fetch()\` → \`CAT.agent.opfs.write(blob)\` → returns path +**Use**: \`opfs_read(path)\` → returns \`blob:chrome-extension://\` URL → pass to a SkillScript that runs in ISOLATED world, which can \`fetch()\` the blob URL and manipulate page DOM +**Note**: Blob URLs are scoped to the extension origin. \`execute_script\` runs in MAIN world and **cannot** access blob URLs. Use a SkillScript (ISOLATED world) for blob URL operations.`; + +// 合并后与原始 BUILTIN_SYSTEM_PROMPT 完全一致 +const BUILTIN_SYSTEM_PROMPT = [ + SECTION_INTRO, + SECTION_CORE_PRINCIPLES, + SECTION_PLANNING, + SECTION_TOOL_USAGE, + SECTION_SAFETY, + SECTION_COMMUNICATION, + SECTION_TOOL_GUIDE, + SECTION_SUB_AGENT, + SECTION_TASK_MANAGEMENT, + SECTION_OPFS, +].join("\n\n"); + +// ===================== 子代理系统提示词各段 ===================== + +const SUB_AGENT_SECTION_INTRO = `You are a ScriptCat sub-agent, an AI assistant executing a specific subtask delegated by the parent agent. Focus on completing the assigned task efficiently and returning clear results.`; + +const SUB_AGENT_SECTION_CORE_PRINCIPLES = `## Core Principles + +- Before interacting with a page, verify its current state — never assume a page is as expected. +- When a step fails, analyze the cause and change your approach. Never retry the exact same action. +- If you cannot complete the task, describe the obstacle clearly in your final response so the parent agent can decide next steps.`; + +const SUB_AGENT_SECTION_PLANNING = `## Planning + +- **Simple tasks** (single step, clear intent): act directly. +- **Complex tasks** (multi-step): + 1. **Think first** — Analyze the task and design an execution plan before making any tool call. + 2. **Execute methodically** — Follow the plan step by step. +- If the situation deviates from the plan, adapt your approach. If you cannot proceed, describe the problem in your final response. +- **Avoid speculative chains** — Do not chain multiple uncertain actions hoping they will work. If the first step's outcome is uncertain, verify before proceeding.`; + +const SUB_AGENT_SECTION_TOOL_USAGE = `## Tool Usage + +Read each tool's description before calling — it defines behavior, parameters, and constraints. When a tool returns an error, read the error message and adapt — do not blindly retry. + +**Tool call budget**: You have a limited number of tool calls. Use them wisely — plan before acting, combine steps when possible, and stop early if stuck. + +### Failure Detection — Stop Early + +**How to judge failure**: Compare the actual outcome against your intent. If you have no way to confirm the effect, treat uncertain results as potential failures. + +**Failure limits — hard rules:** +- **1st failure**: Try ONE different approach. +- **2nd failure**: **STOP immediately.** Describe the issue in your final response. +- **Same tool + same arguments**: Never call the exact same thing twice. +- **3+ tool calls without meaningful progress**: Stop and report. + +### Escalation +When stopped, describe clearly in your final response: +1. What you tried and what happened. +2. Your best guess at the root cause. +Never silently keep trying — fail fast and report. + +**System guard**: The system automatically detects repetitive tool call patterns and will warn you with a \`[System Warning]\` message. If you receive one, follow its guidance immediately.`; + +// 页面交互工作流指南(仅有 tab 工具时包含) +const SUB_AGENT_SECTION_PAGE_INTERACTION = `### Page Interaction Workflow + +1. **Discover first** — Call \`get_tab_content\` with a prompt like "find the title input, content editor, and submit button — return their CSS selectors and current state". The response includes \`\` annotations for key elements. +2. **Act with known selectors** — Use the selectors from step 1 in \`execute_script\`. Never guess selectors. +3. **Verify with \`execute_script\`** — After an action, check the result with a targeted script (e.g., \`return document.querySelector('#title').value\`). Do NOT call \`get_tab_content\` again just to verify a small action. +4. **Re-read only after major changes** — Only call \`get_tab_content\` again after navigation or a major DOM change.`; + +const SUB_AGENT_SECTION_SAFETY = `## Safety + +- **Be conservative with irreversible actions**: submitting forms, making purchases, deleting data, posting content. Only proceed if the task clearly requires it. +- **Proceed freely on read-only actions**: navigating, reading content, taking screenshots, extracting data. +- **Never fill sensitive data you invented** — only use credentials or personal info provided in the task prompt. +- **Never bypass site security** — do not attempt to circumvent CAPTCHAs, rate limits, or access controls.`; + +const SUB_AGENT_SECTION_COMMUNICATION = `## Communication + +- Keep your intermediate responses minimal — focus on actions. +- Your final response will be returned to the parent agent. Use this structure: + - **Result**: The key findings or outcomes — be specific and factual. + - **Data**: Any extracted data in structured format (lists, tables). Omit if not applicable. + - **Issues**: Problems encountered or things that need attention. Omit if none. +- Keep your final response under 500 words unless the task requires more. Be factual and concise.`; + +// 工具指南条目映射:工具名 → 指南文本 +// 使用数组保持顺序,同一工具可以有多个条目(条件不同) +const TOOL_GUIDE_ENTRIES: Array<{ tools: string[]; guide: string }> = [ + { + tools: ["get_tab_content"], + guide: `- **Read page content & get selectors** → \`get_tab_content\` returns markdown with CSS selector annotations. Always call this first before interacting with a page.`, + }, + { + tools: ["web_fetch"], + guide: `- **Fetch remote data** → \`web_fetch\` for text/HTML/JSON. It does NOT support binary downloads.`, + }, + { + // 页面 DOM 交互仅在有 tab 工具时展示 + tools: ["execute_script", "get_tab_content"], + guide: `- **Interact with page DOM** → \`execute_script(target='page')\` using selectors obtained from \`get_tab_content\`. Never guess selectors.`, + }, + { + tools: ["execute_script"], + guide: `- **Compute without DOM** → \`execute_script(target='sandbox')\` for data processing, text parsing, calculations.`, + }, + { + tools: ["web_search"], + guide: `- **Search the web** → \`web_search\` returns titles, URLs, and snippets. Follow up with \`web_fetch\` to read specific results.`, + }, +]; + +/** + * 根据可用工具名列表动态生成工具选择指南 + * 只有当条目所需的所有工具都可用时才包含该条目 + */ +function buildToolGuideForTools(availableToolNames: string[]): string { + const nameSet = new Set(availableToolNames); + const entries: string[] = []; + + for (const entry of TOOL_GUIDE_ENTRIES) { + if (entry.tools.every((t) => nameSet.has(t))) { + entries.push(entry.guide); + } + } + + if (entries.length === 0) return ""; + return `## Tool Selection Guide\n\n${entries.join("\n")}`; +} + +// ===================== 公共 API ===================== + +// Skill 摘要提示词模板 +export const SKILL_SUFFIX_HEADER = `--- + +# Available Skills + +Skills extend your capabilities with specialized workflows and scripts. **You must call \`load_skill\` before using any skill** — this loads the skill's detailed instructions and lists its available scripts. + +Rules: +- Only load skills that are relevant to the current task. +- After loading, follow the skill's instructions carefully — they override general guidelines for that domain. +- Use \`execute_skill_script\` to run a skill's scripts. Pass the skill name, script name, and parameters. +- If a skill has reference documents, use \`read_reference\` to access them when needed. + +Installed skills: +`; + +export interface BuildSystemPromptOptions { + /** 用户自定义 system prompt */ + userSystem?: string; + /** skill 解析后追加的提示词后缀 */ + skillSuffix?: string; +} + +/** + * 组装完整的 system prompt:内置提示词 + 用户自定义 + skill 后缀 + */ +export function buildSystemPrompt(options: BuildSystemPromptOptions): string { + const parts: string[] = [BUILTIN_SYSTEM_PROMPT]; + + if (options.userSystem) { + parts.push(options.userSystem); + } + + if (options.skillSuffix) { + parts.push(options.skillSuffix); + } + + return parts.join("\n\n"); +} + +/** + * 组装子代理的 system prompt:子代理专用基础提示词 + 类型角色说明 + 动态工具指南 + 条件 OPFS 段 + */ +export function buildSubAgentSystemPrompt(typeConfig: SubAgentTypeConfig, availableToolNames: string[]): string { + const nameSet = new Set(availableToolNames); + const hasOpfs = nameSet.has("opfs_read") || nameSet.has("opfs_write"); + const hasTabTools = nameSet.has("get_tab_content"); + + const sections: string[] = [ + SUB_AGENT_SECTION_INTRO, + typeConfig.systemPromptAddition, + SUB_AGENT_SECTION_CORE_PRINCIPLES, + SUB_AGENT_SECTION_PLANNING, + SUB_AGENT_SECTION_TOOL_USAGE, + ]; + + // 有 tab 工具时才包含页面交互验证指南 + if (hasTabTools) { + sections.push(SUB_AGENT_SECTION_PAGE_INTERACTION); + } + + sections.push(SUB_AGENT_SECTION_SAFETY, SUB_AGENT_SECTION_COMMUNICATION, buildToolGuideForTools(availableToolNames)); + + if (hasOpfs) { + sections.push(SECTION_OPFS); + } + + return sections.filter(Boolean).join("\n\n"); +} + +// 导出原始 prompt 供测试断言 +export { BUILTIN_SYSTEM_PROMPT as _BUILTIN_SYSTEM_PROMPT_FOR_TEST }; diff --git a/src/app/service/agent/core/task_scheduler.test.ts b/src/app/service/agent/core/task_scheduler.test.ts new file mode 100644 index 000000000..8b67dbeaa --- /dev/null +++ b/src/app/service/agent/core/task_scheduler.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { AgentTaskScheduler } from "./task_scheduler"; +import { AgentTaskRepo, AgentTaskRunRepo } from "@App/app/repo/agent_task"; +import type { AgentTask } from "@App/app/service/agent/core/types"; + +// Mock OPFS 文件系统(AgentTaskRunRepo 使用 OPFS 存储) +function createMockOPFS() { + function createMockWritable() { + let data: any = null; + return { + write: vi.fn(async (content: any) => { + data = content; + }), + close: vi.fn(async () => {}), + getData: () => data, + }; + } + function createMockFileHandle(name: string, dir: Map) { + return { + kind: "file" as const, + getFile: vi.fn(async () => { + const content = dir.get(name); + if (typeof content === "string") return new Blob([content], { type: "application/json" }); + return new Blob([""], { type: "application/json" }); + }), + createWritable: vi.fn(async () => { + const writable = createMockWritable(); + const origClose = writable.close; + writable.close = vi.fn(async () => { + dir.set(name, writable.getData()); + await origClose(); + }); + return writable; + }), + }; + } + function createMockDirHandle(store: Map): any { + return { + kind: "directory" as const, + getDirectoryHandle: vi.fn(async (name: string, opts?: { create?: boolean }) => { + if (!store.has("__dir__" + name)) { + if (opts?.create) store.set("__dir__" + name, new Map()); + else throw new Error("Not found"); + } + return createMockDirHandle(store.get("__dir__" + name)); + }), + getFileHandle: vi.fn(async (name: string, opts?: { create?: boolean }) => { + if (!store.has(name) && !opts?.create) throw new Error("Not found"); + if (!store.has(name)) store.set(name, ""); + return createMockFileHandle(name, store); + }), + removeEntry: vi.fn(async (name: string) => { + store.delete(name); + store.delete("__dir__" + name); + }), + }; + } + const rootStore = new Map(); + const mockRoot = createMockDirHandle(rootStore); + Object.defineProperty(navigator, "storage", { + value: { getDirectory: vi.fn(async () => mockRoot) }, + configurable: true, + writable: true, + }); +} + +function makeTask(overrides: Partial = {}): AgentTask { + return { + id: "task-1", + name: "测试任务", + crontab: "0 9 * * *", + mode: "internal", + enabled: true, + notify: false, + createtime: Date.now(), + updatetime: Date.now(), + ...overrides, + }; +} + +describe("AgentTaskScheduler", () => { + let repo: AgentTaskRepo; + let runRepo: AgentTaskRunRepo; + let internalExecutor: ReturnType< + typeof vi.fn< + (task: AgentTask) => Promise<{ conversationId: string; usage?: { inputTokens: number; outputTokens: number } }> + > + >; + let eventEmitter: ReturnType Promise>>; + let scheduler: AgentTaskScheduler; + + beforeEach(() => { + createMockOPFS(); + repo = new AgentTaskRepo(); + runRepo = new AgentTaskRunRepo(); + internalExecutor = vi + .fn() + .mockResolvedValue({ conversationId: "conv-1", usage: { inputTokens: 100, outputTokens: 50 } }); + eventEmitter = vi.fn().mockResolvedValue(undefined); + scheduler = new AgentTaskScheduler(repo, runRepo, internalExecutor, eventEmitter); + }); + + it("init 加载并计算 nextruntime", async () => { + const task = makeTask({ id: "init-1", nextruntime: undefined }); + await repo.saveTask(task); + + await scheduler.init(); + + const updated = await repo.getTask("init-1"); + expect(updated).toBeDefined(); + expect(updated!.nextruntime).toBeGreaterThan(Date.now() - 1000); + }); + + it("tick 执行到期任务", async () => { + const task = makeTask({ id: "tick-1", nextruntime: Date.now() - 1000 }); + await repo.saveTask(task); + + await scheduler.tick(); + + // 等待异步执行完成 + await vi.waitFor(async () => { + expect(internalExecutor).toHaveBeenCalledTimes(1); + }); + }); + + it("tick 跳过未到期任务", async () => { + const task = makeTask({ id: "skip-1", nextruntime: Date.now() + 60_000 }); + await repo.saveTask(task); + + await scheduler.tick(); + + expect(internalExecutor).not.toHaveBeenCalled(); + }); + + it("tick 跳过 disabled 任务", async () => { + const task = makeTask({ id: "disabled-1", enabled: false, nextruntime: Date.now() - 1000 }); + await repo.saveTask(task); + + await scheduler.tick(); + + expect(internalExecutor).not.toHaveBeenCalled(); + }); + + it("tick 跳过正在运行的任务", async () => { + // 让 executor 永远 pending + internalExecutor.mockReturnValue(new Promise(() => {})); + + const task = makeTask({ id: "running-1", nextruntime: Date.now() - 1000 }); + await repo.saveTask(task); + + await scheduler.tick(); + + // 等待 executor 被调用(表明已经过 appendRun) + await vi.waitFor(() => { + expect(internalExecutor).toHaveBeenCalledTimes(1); + }); + expect(scheduler.isRunning("running-1")).toBe(true); + + // 再次 tick 不应重复执行 + await scheduler.tick(); + expect(internalExecutor).toHaveBeenCalledTimes(1); + }); + + it("internal 模式调 internalExecutor", async () => { + const task = makeTask({ id: "internal-1", mode: "internal", nextruntime: Date.now() - 1000 }); + await repo.saveTask(task); + + await scheduler.executeTask(task); + + expect(internalExecutor).toHaveBeenCalledTimes(1); + expect(internalExecutor).toHaveBeenCalledWith(expect.objectContaining({ id: "internal-1" })); + expect(eventEmitter).not.toHaveBeenCalled(); + + // 检查 run 记录 + const runs = await runRepo.listRuns("internal-1"); + expect(runs).toHaveLength(1); + expect(runs[0].status).toBe("success"); + expect(runs[0].conversationId).toBe("conv-1"); + expect(runs[0].usage).toEqual({ inputTokens: 100, outputTokens: 50 }); + }); + + it("event 模式调 eventEmitter", async () => { + const task = makeTask({ id: "event-1", mode: "event", nextruntime: Date.now() - 1000 }); + await repo.saveTask(task); + + await scheduler.executeTask(task); + + expect(eventEmitter).toHaveBeenCalledTimes(1); + expect(internalExecutor).not.toHaveBeenCalled(); + + const runs = await runRepo.listRuns("event-1"); + expect(runs).toHaveLength(1); + expect(runs[0].status).toBe("success"); + }); + + it("执行失败更新 error 状态", async () => { + internalExecutor.mockRejectedValue(new Error("LLM 调用失败")); + + const task = makeTask({ id: "error-1", nextruntime: Date.now() - 1000 }); + await repo.saveTask(task); + + await scheduler.executeTask(task); + + const runs = await runRepo.listRuns("error-1"); + expect(runs).toHaveLength(1); + expect(runs[0].status).toBe("error"); + expect(runs[0].error).toBe("LLM 调用失败"); + + const updatedTask = await repo.getTask("error-1"); + expect(updatedTask!.lastRunStatus).toBe("error"); + expect(updatedTask!.lastRunError).toBe("LLM 调用失败"); + }); + + it("执行完成后更新 nextruntime", async () => { + const task = makeTask({ id: "next-1", nextruntime: Date.now() - 1000 }); + await repo.saveTask(task); + + await scheduler.executeTask(task); + + const updated = await repo.getTask("next-1"); + expect(updated!.nextruntime).toBeGreaterThan(Date.now() - 1000); + expect(updated!.lastruntime).toBeDefined(); + }); + + it("appendRun 抛错时,task.id 应从 runningTasks 移除", async () => { + runRepo.appendRun = vi.fn().mockRejectedValue(new Error("storage quota exceeded")); + + const task = makeTask({ id: "append-fail-1", nextruntime: Date.now() - 1000 }); + await repo.saveTask(task); + + // 第一次 executeTask 时 appendRun 抛错,应不再阻塞任务 + await expect(scheduler.executeTask(task)).rejects.toThrow("storage quota exceeded"); + + // task.id 应已从 runningTasks 移除 + expect(scheduler.isRunning("append-fail-1")).toBe(false); + + // 第二次调用应能正常进入(不被跳过) + runRepo.appendRun = vi.fn().mockResolvedValue(undefined); + internalExecutor.mockResolvedValue({ conversationId: "conv-2", usage: { inputTokens: 200, outputTokens: 100 } }); + + // 应能成功执行而不被 runningTasks.has() 阻挡 + await scheduler.executeTask(task); + expect(internalExecutor).toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/agent/core/task_scheduler.ts b/src/app/service/agent/core/task_scheduler.ts new file mode 100644 index 000000000..4a5803d30 --- /dev/null +++ b/src/app/service/agent/core/task_scheduler.ts @@ -0,0 +1,120 @@ +import type { AgentTask, AgentTaskRun } from "@App/app/service/agent/core/types"; +import type { AgentTaskRepo, AgentTaskRunRepo } from "@App/app/repo/agent_task"; +import { nextTimeInfo } from "@App/pkg/utils/cron"; +import { uuidv4 } from "@App/pkg/utils/uuid"; + +export type InternalExecutor = (task: AgentTask) => Promise<{ + conversationId: string; + usage?: { inputTokens: number; outputTokens: number }; +}>; + +export type EventEmitter = (task: AgentTask) => Promise; + +export class AgentTaskScheduler { + private runningTasks = new Set(); + + constructor( + private repo: AgentTaskRepo, + private runRepo: AgentTaskRunRepo, + private internalExecutor: InternalExecutor, + private eventEmitter: EventEmitter + ) {} + + async init(): Promise { + // 加载所有 enabled 任务,计算 nextruntime + const tasks = await this.repo.listTasks(); + for (const task of tasks) { + if (task.enabled && !task.nextruntime) { + try { + const info = nextTimeInfo(task.crontab); + task.nextruntime = info.next.toMillis(); + task.updatetime = Date.now(); + await this.repo.saveTask(task); + } catch { + // cron 表达式无效,跳过 + } + } + } + } + + async tick(now?: number): Promise { + const currentTime = now ?? Date.now(); + const tasks = await this.repo.listTasks(); + + for (const task of tasks) { + if (!task.enabled) continue; + if (this.runningTasks.has(task.id)) continue; + if (!task.nextruntime || task.nextruntime > currentTime) continue; + + // 不 await,并行执行多个任务 + this.executeTask(task).catch(() => { + // 错误已在 executeTask 内部处理 + }); + } + } + + async executeTask(task: AgentTask): Promise { + if (this.runningTasks.has(task.id)) return; + this.runningTasks.add(task.id); + + try { + const run: AgentTaskRun = { + id: uuidv4(), + taskId: task.id, + starttime: Date.now(), + status: "running", + }; + await this.runRepo.appendRun(run); + + try { + if (task.mode === "internal") { + const result = await this.internalExecutor(task); + run.conversationId = result.conversationId; + run.usage = result.usage; + } else { + await this.eventEmitter(task); + } + + run.status = "success"; + run.endtime = Date.now(); + + task.lastRunStatus = "success"; + task.lastRunError = undefined; + } catch (e: any) { + run.status = "error"; + run.error = e.message || "Unknown error"; + run.endtime = Date.now(); + + task.lastRunStatus = "error"; + task.lastRunError = run.error; + } finally { + // 更新 run 记录 + await this.runRepo.updateRun(task.id, run.id, { + status: run.status, + endtime: run.endtime, + error: run.error, + conversationId: run.conversationId, + usage: run.usage, + }); + + // 更新 task 状态 + task.lastruntime = run.starttime; + try { + const info = nextTimeInfo(task.crontab); + task.nextruntime = info.next.toMillis(); + } catch { + task.nextruntime = undefined; + } + task.updatetime = Date.now(); + await this.repo.saveTask(task); + } + } finally { + // 必须在最外层 finally 确保任何异常都能清理 runningTasks + this.runningTasks.delete(task.id); + } + } + + isRunning(taskId: string): boolean { + return this.runningTasks.has(taskId); + } +} diff --git a/src/app/service/agent/core/tool_call_guard.test.ts b/src/app/service/agent/core/tool_call_guard.test.ts new file mode 100644 index 000000000..6c443c189 --- /dev/null +++ b/src/app/service/agent/core/tool_call_guard.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect } from "vitest"; +import { detectToolCallIssues, type ToolCallRecord } from "./tool_call_guard"; + +describe("detectToolCallIssues", () => { + it("历史记录不足时不生成警告", () => { + expect(detectToolCallIssues([])).toBeNull(); + expect( + detectToolCallIssues([{ name: "web_search", args: '{"query":"test"}', result: "...", iteration: 1 }]) + ).toBeNull(); + }); + + describe("完全相同的 tool + args 检测", () => { + it("相同工具和参数调用2次时生成警告", () => { + const history: ToolCallRecord[] = [ + { name: "web_fetch", args: '{"url":"https://example.com"}', result: "...", iteration: 1 }, + { name: "web_fetch", args: '{"url":"https://example.com"}', result: "...", iteration: 2 }, + ]; + const warning = detectToolCallIssues(history); + expect(warning).not.toBeNull(); + expect(warning).toContain("web_fetch"); + }); + + it("JSON 格式不同但内容相同时也触发", () => { + const history: ToolCallRecord[] = [ + { name: "web_fetch", args: '{"url": "https://example.com"}', result: "...", iteration: 1 }, + { name: "web_fetch", args: '{"url":"https://example.com"}', result: "...", iteration: 2 }, + ]; + const warning = detectToolCallIssues(history); + expect(warning).not.toBeNull(); + }); + + it("不同参数不触发警告", () => { + const history: ToolCallRecord[] = [ + { name: "web_fetch", args: '{"url":"https://a.com"}', result: "...", iteration: 1 }, + { name: "web_fetch", args: '{"url":"https://b.com"}', result: "...", iteration: 2 }, + ]; + expect(detectToolCallIssues(history)).toBeNull(); + }); + + it("超过最近10条的重复不触发", () => { + const history: ToolCallRecord[] = [ + { name: "web_fetch", args: '{"url":"https://old.com"}', result: "...", iteration: 1 }, + ]; + // 插入11条不同的调用(交替使用不同工具避免触发通用重复检测) + const tools = ["web_search", "web_fetch", "execute_script"]; + for (let i = 0; i < 11; i++) { + history.push({ + name: tools[i % 3], + args: `{"q":"pad${i}"}`, + result: '{"result":"ok"}', + iteration: i + 2, + }); + } + // 再加一条与第1条相同的,但已超出最近10条窗口 + history.push({ name: "web_fetch", args: '{"url":"https://old.com"}', result: "...", iteration: 13 }); + expect(detectToolCallIssues(history)).toBeNull(); + }); + }); + + describe("execute_script 返回 null 检测", () => { + it("连续3次返回 null 时生成警告", () => { + const history: ToolCallRecord[] = [ + { + name: "execute_script", + args: '{"code":"a.click()","target":"page"}', + result: '{"result":null,"target":"page","tab_id":123}', + iteration: 1, + }, + { + name: "execute_script", + args: '{"code":"b.click()","target":"page"}', + result: '{"result":null,"target":"page","tab_id":123}', + iteration: 2, + }, + { + name: "execute_script", + args: '{"code":"c.click()","target":"page"}', + result: '{"result":null,"target":"page","tab_id":123}', + iteration: 3, + }, + ]; + const warning = detectToolCallIssues(history); + expect(warning).not.toBeNull(); + expect(warning).toContain("execute_script"); + expect(warning).toContain("return"); + }); + + it("中间穿插其他工具但 execute_script 仍然连续 null 时触发", () => { + const history: ToolCallRecord[] = [ + { name: "execute_script", args: '{"code":"a()"}', result: '{"result":null}', iteration: 1 }, + { + name: "get_tab_content", + args: '{"tab_id":1,"prompt":"find buttons"}', + result: "page content...", + iteration: 2, + }, + { name: "execute_script", args: '{"code":"b()"}', result: '{"result":null}', iteration: 3 }, + { + name: "get_tab_content", + args: '{"tab_id":1,"prompt":"check state"}', + result: "page content...", + iteration: 4, + }, + { name: "execute_script", args: '{"code":"c()"}', result: '{"result":null}', iteration: 5 }, + ]; + const warning = detectToolCallIssues(history); + expect(warning).not.toBeNull(); + expect(warning).toContain("execute_script"); + }); + + it("2次返回 null 不触发", () => { + const history: ToolCallRecord[] = [ + { name: "execute_script", args: '{"code":"a()"}', result: '{"result":null}', iteration: 1 }, + { name: "execute_script", args: '{"code":"b()"}', result: '{"result":null}', iteration: 2 }, + ]; + expect(detectToolCallIssues(history)).toBeNull(); + }); + + it("中间有非 null 结果打断连续计数", () => { + const history: ToolCallRecord[] = [ + { name: "execute_script", args: '{"code":"a()"}', result: '{"result":null}', iteration: 1 }, + { name: "execute_script", args: '{"code":"b()"}', result: '{"result":"ok"}', iteration: 2 }, + { name: "execute_script", args: '{"code":"c()"}', result: '{"result":null}', iteration: 3 }, + { name: "execute_script", args: '{"code":"d()"}', result: '{"result":null}', iteration: 4 }, + ]; + // 从最新往回数只有2个连续 null,不足3个 + expect(detectToolCallIssues(history)).toBeNull(); + }); + }); + + describe("get_tab_content 重复调用检测", () => { + it("同一 tab 调用3次时生成警告", () => { + const history: ToolCallRecord[] = [ + { name: "get_tab_content", args: '{"tab_id":123,"prompt":"find buttons"}', result: "...", iteration: 1 }, + { name: "execute_script", args: '{"code":"click()"}', result: '{"result":"ok"}', iteration: 2 }, + { name: "get_tab_content", args: '{"tab_id":123,"prompt":"find the button"}', result: "...", iteration: 3 }, + { name: "execute_script", args: '{"code":"click2()"}', result: '{"result":"ok"}', iteration: 4 }, + { name: "get_tab_content", args: '{"tab_id":123,"prompt":"detailed info"}', result: "...", iteration: 5 }, + ]; + const warning = detectToolCallIssues(history); + expect(warning).not.toBeNull(); + expect(warning).toContain("get_tab_content"); + }); + + it("不同 tab 不触发", () => { + const history: ToolCallRecord[] = [ + { name: "get_tab_content", args: '{"tab_id":123}', result: "...", iteration: 1 }, + { name: "get_tab_content", args: '{"tab_id":456}', result: "...", iteration: 2 }, + { name: "get_tab_content", args: '{"tab_id":789}', result: "...", iteration: 3 }, + ]; + expect(detectToolCallIssues(history)).toBeNull(); + }); + }); + + describe("通用重复调用检测", () => { + it("最近8条中同一工具出现5次时生成警告", () => { + const history: ToolCallRecord[] = []; + for (let i = 1; i <= 5; i++) { + history.push({ + name: "web_search", + args: `{"query":"search ${i}"}`, + result: "...", + iteration: i, + }); + } + const warning = detectToolCallIssues(history); + expect(warning).not.toBeNull(); + expect(warning).toContain("web_search"); + }); + + it("查询类工具不参与通用计数", () => { + const history: ToolCallRecord[] = []; + for (let i = 1; i <= 6; i++) { + history.push({ name: "list_tasks", args: "{}", result: "[]", iteration: i }); + } + expect(detectToolCallIssues(history)).toBeNull(); + }); + + it("不同工具不合并计数", () => { + const history: ToolCallRecord[] = [ + { name: "web_search", args: '{"query":"a"}', result: "...", iteration: 1 }, + { name: "web_fetch", args: '{"url":"b"}', result: "...", iteration: 2 }, + { name: "web_search", args: '{"query":"c"}', result: "...", iteration: 3 }, + { name: "web_fetch", args: '{"url":"d"}', result: "...", iteration: 4 }, + { name: "web_search", args: '{"query":"e"}', result: "...", iteration: 5 }, + { name: "web_fetch", args: '{"url":"f"}', result: "...", iteration: 6 }, + ]; + expect(detectToolCallIssues(history)).toBeNull(); + }); + }); + + describe("startIndex 防止重复警告", () => { + it("使用 startIndex 跳过已警告过的记录后不再重复触发", () => { + const history: ToolCallRecord[] = [ + { name: "get_tab_content", args: '{"tab_id":123,"prompt":"a"}', result: "...", iteration: 1 }, + { name: "get_tab_content", args: '{"tab_id":123,"prompt":"b"}', result: "...", iteration: 2 }, + { name: "get_tab_content", args: '{"tab_id":123,"prompt":"c"}', result: "...", iteration: 3 }, + ]; + // 第一次检测:触发警告 + const warning1 = detectToolCallIssues(history); + expect(warning1).not.toBeNull(); + expect(warning1).toContain("get_tab_content"); + + // 模拟警告后推进 startIndex + const startIndex = history.length; + + // 后续添加不同工具调用 + history.push({ name: "execute_script", args: '{"code":"click()"}', result: '{"result":"ok"}', iteration: 4 }); + history.push({ name: "list_tabs", args: "{}", result: "[]", iteration: 5 }); + + // 使用 startIndex 后不再触发 + expect(detectToolCallIssues(history, startIndex)).toBeNull(); + }); + + it("startIndex 之后出现新的违规模式仍然能检测到", () => { + const history: ToolCallRecord[] = [ + { name: "get_tab_content", args: '{"tab_id":123,"prompt":"a"}', result: "...", iteration: 1 }, + { name: "get_tab_content", args: '{"tab_id":123,"prompt":"b"}', result: "...", iteration: 2 }, + { name: "get_tab_content", args: '{"tab_id":123,"prompt":"c"}', result: "...", iteration: 3 }, + ]; + const startIndex = history.length; + + // 新增的调用在 startIndex 之后再次触发相同问题 + history.push({ name: "get_tab_content", args: '{"tab_id":456,"prompt":"d"}', result: "...", iteration: 4 }); + history.push({ name: "get_tab_content", args: '{"tab_id":456,"prompt":"e"}', result: "...", iteration: 5 }); + history.push({ name: "get_tab_content", args: '{"tab_id":456,"prompt":"f"}', result: "...", iteration: 6 }); + + const warning = detectToolCallIssues(history, startIndex); + expect(warning).not.toBeNull(); + expect(warning).toContain("get_tab_content"); + }); + }); + + describe("优先级", () => { + it("完全相同参数的 execute_script 优先触发重复检测而非 null 检测", () => { + const history: ToolCallRecord[] = [ + { name: "execute_script", args: '{"code":"a()"}', result: '{"result":null}', iteration: 1 }, + { name: "execute_script", args: '{"code":"b()"}', result: '{"result":null}', iteration: 2 }, + { name: "execute_script", args: '{"code":"a()"}', result: '{"result":null}', iteration: 3 }, + ]; + const warning = detectToolCallIssues(history); + expect(warning).not.toBeNull(); + // 应该触发重复检测(规则1),而不是 null 检测(规则2) + expect(warning).toContain("identical arguments"); + }); + }); +}); diff --git a/src/app/service/agent/core/tool_call_guard.ts b/src/app/service/agent/core/tool_call_guard.ts new file mode 100644 index 000000000..d7848a38d --- /dev/null +++ b/src/app/service/agent/core/tool_call_guard.ts @@ -0,0 +1,162 @@ +// 工具调用模式检测 — 在 agent loop 中检测重复/循环调用并生成针对性提醒 + +export interface ToolCallRecord { + name: string; + args: string; // 原始 JSON + result: string; // 工具返回的字符串 + iteration: number; +} + +/** + * 规范化参数字符串(消除 JSON 格式差异) + */ +function normalizeArgs(args: string): string { + try { + return JSON.stringify(JSON.parse(args)); + } catch { + return args; + } +} + +/** + * 判断 execute_script 的结果是否为 null(脚本没有 return) + */ +function isNullResult(result: string): boolean { + try { + const parsed = JSON.parse(result); + return parsed.result === null || parsed.result === undefined; + } catch { + return false; + } +} + +// 不参与通用重复计数的查询类工具 +const QUERY_TOOLS = new Set(["list_tasks", "get_task", "list_tabs"]); + +/** + * 检测:完全相同的 tool + args 被调用2次 + */ +function checkDuplicateCalls(history: ToolCallRecord[]): string | null { + const recent = history.slice(-10); + const seen = new Map(); + + for (const h of recent) { + // 查询类工具频繁重复调用是正常的 + if (QUERY_TOOLS.has(h.name)) continue; + const key = `${h.name}::${normalizeArgs(h.args)}`; + const count = (seen.get(key) || 0) + 1; + seen.set(key, count); + if (count >= 2) { + return `[System Warning] You called \`${h.name}\` with identical arguments ${count} times. This violates the "never call the same tool with same arguments twice" rule. Change your approach or use ask_user.`; + } + } + return null; +} + +/** + * 检测:execute_script 从最新往回数连续返回 null ≥ 3次 + */ +function checkExecuteScriptNulls(history: ToolCallRecord[]): string | null { + const execCalls = history.filter((h) => h.name === "execute_script"); + if (execCalls.length < 3) return null; + + let consecutiveNulls = 0; + for (let i = execCalls.length - 1; i >= 0; i--) { + if (isNullResult(execCalls[i].result)) { + consecutiveNulls++; + } else { + break; + } + } + + if (consecutiveNulls >= 3) { + return ( + `[System Warning] execute_script returned null ${consecutiveNulls} consecutive times. Common causes:\n` + + "1. Your code lacks a `return` statement — use `return value` instead of `console.log(value)`.\n" + + "2. Your action may have already succeeded (e.g., opened a new tab) — check with `list_tabs`.\n" + + "3. The selectors might be wrong — re-read the page with `get_tab_content`.\n" + + "Stop retrying the same approach. Try a completely different method, or use ask_user to get help." + ); + } + + return null; +} + +/** + * 检测:get_tab_content 对同一 tab_id 调用 ≥ 3次 + */ +function checkGetTabContentRepetition(history: ToolCallRecord[]): string | null { + const getContentCalls = history.filter((h) => h.name === "get_tab_content"); + if (getContentCalls.length < 3) return null; + + const tabCounts = new Map(); + for (const h of getContentCalls) { + try { + const args = JSON.parse(h.args); + const tabId = String(args.tab_id || "unknown"); + tabCounts.set(tabId, (tabCounts.get(tabId) || 0) + 1); + } catch { + continue; + } + } + + for (const [, count] of tabCounts) { + if (count >= 3) { + return `[System Warning] You called \`get_tab_content\` ${count} times on the same tab. You already have enough page information — act on it with \`execute_script\` instead of re-reading. If you're stuck, use ask_user.`; + } + } + + return null; +} + +/** + * 检测:最近8条调用中同一工具出现 ≥ 5次(排除查询类工具) + */ +function checkGenericRepetition(history: ToolCallRecord[]): string | null { + const recent = history.slice(-8); + const toolCounts = new Map(); + + for (const h of recent) { + if (QUERY_TOOLS.has(h.name)) continue; + toolCounts.set(h.name, (toolCounts.get(h.name) || 0) + 1); + } + + for (const [name, count] of toolCounts) { + if (count >= 5) { + return `[System Warning] You called \`${name}\` ${count} times in recent iterations without meaningful progress. You may be stuck in a loop. Stop and reassess your approach, or use ask_user to get help.`; + } + } + + return null; +} + +/** + * 分析工具调用历史,检测重复/循环模式并生成针对性的提醒消息。 + * 按优先级检测,命中即返回。返回 null 表示没有检测到问题。 + * + * @param startIndex 只检测 history[startIndex:] 的记录。 + * 调用方应在每次收到警告后将 startIndex 推进到当前 history.length, + * 避免已经警告过的旧记录持续触发同一条警告。 + */ +export function detectToolCallIssues(history: ToolCallRecord[], startIndex = 0): string | null { + const relevantHistory = startIndex > 0 ? history.slice(startIndex) : history; + if (relevantHistory.length < 2) return null; + + // 规则1: 完全相同的 tool + args + const duplicateWarning = checkDuplicateCalls(relevantHistory); + if (duplicateWarning) return duplicateWarning; + + // 规则2: execute_script 连续返回 null + const executeNullWarning = checkExecuteScriptNulls(relevantHistory); + if (executeNullWarning) return executeNullWarning; + + // 规则3: get_tab_content 对同一 tab 重复调用 + const getContentWarning = checkGetTabContentRepetition(relevantHistory); + if (getContentWarning) return getContentWarning; + + // 规则4: 通用重复检测(兜底) + const genericWarning = checkGenericRepetition(relevantHistory); + if (genericWarning) return genericWarning; + + return null; +} diff --git a/src/app/service/agent/core/tool_registry.test.ts b/src/app/service/agent/core/tool_registry.test.ts new file mode 100644 index 000000000..13cc8399e --- /dev/null +++ b/src/app/service/agent/core/tool_registry.test.ts @@ -0,0 +1,622 @@ +import { describe, it, expect, vi } from "vitest"; +import { ToolRegistry } from "./tool_registry"; +import type { ToolExecutor } from "./tool_registry"; +import type { ToolCall, ToolDefinition, ToolResultWithAttachments } from "./types"; +import type { AgentChatRepo } from "@App/app/repo/agent_chat"; + +// 创建一个简单的 mock executor +function createExecutor(fn: (args: Record) => Promise): ToolExecutor { + return { execute: fn }; +} + +const weatherDef: ToolDefinition = { + name: "get_weather", + description: "获取天气", + parameters: { type: "object", properties: { city: { type: "string" } } }, +}; + +const calcDef: ToolDefinition = { + name: "calc", + description: "计算器", + parameters: { type: "object", properties: { expr: { type: "string" } } }, +}; + +describe("ToolRegistry", () => { + describe("registerBuiltin / unregisterBuiltin", () => { + it("应正确注册和注销内置工具", () => { + const registry = new ToolRegistry(); + const executor = createExecutor(async () => "ok"); + registry.registerBuiltin(weatherDef, executor); + + expect(registry.getDefinitions()).toHaveLength(1); + expect(registry.getDefinitions()[0].name).toBe("get_weather"); + + const removed = registry.unregisterBuiltin("get_weather"); + expect(removed).toBe(true); + expect(registry.getDefinitions()).toHaveLength(0); + }); + + it("注销不存在的工具应返回 false", () => { + const registry = new ToolRegistry(); + expect(registry.unregisterBuiltin("nonexistent")).toBe(false); + }); + + it("重复注册同名工具应覆盖", () => { + const registry = new ToolRegistry(); + const executor1 = createExecutor(async () => "v1"); + const executor2 = createExecutor(async () => "v2"); + + registry.registerBuiltin(weatherDef, executor1); + registry.registerBuiltin(weatherDef, executor2); + + expect(registry.getDefinitions()).toHaveLength(1); + }); + }); + + describe("getDefinitions", () => { + it("应合并内置和额外的工具定义", () => { + const registry = new ToolRegistry(); + registry.registerBuiltin( + weatherDef, + createExecutor(async () => "") + ); + + const defs = registry.getDefinitions([calcDef]); + expect(defs).toHaveLength(2); + expect(defs.map((d) => d.name)).toEqual(["get_weather", "calc"]); + }); + + it("没有注册工具时返回空数组", () => { + const registry = new ToolRegistry(); + expect(registry.getDefinitions()).toHaveLength(0); + }); + + it("extraTools 为 undefined 时只返回内置工具", () => { + const registry = new ToolRegistry(); + registry.registerBuiltin( + weatherDef, + createExecutor(async () => "") + ); + const defs = registry.getDefinitions(undefined); + expect(defs).toHaveLength(1); + }); + }); + + describe("execute", () => { + it("应正确执行内置工具", async () => { + const registry = new ToolRegistry(); + const executor = createExecutor(async (args) => `${args.city}的天气:晴`); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: '{"city":"北京"}' }]); + + expect(results).toHaveLength(1); + expect(results[0].id).toBe("tc_1"); + expect(results[0].result).toBe("北京的天气:晴"); + }); + + it("内置工具返回非字符串时应 JSON.stringify", async () => { + const registry = new ToolRegistry(); + const executor = createExecutor(async () => ({ temp: 25, unit: "C" })); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: '{"city":"上海"}' }]); + + expect(results[0].result).toBe('{"temp":25,"unit":"C"}'); + }); + + it("空 arguments 时应传入空对象", async () => { + const registry = new ToolRegistry(); + const executeSpy = vi.fn().mockResolvedValue("ok"); + registry.registerBuiltin(weatherDef, { execute: executeSpy }); + + await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "" }]); + + expect(executeSpy).toHaveBeenCalledWith({}); + }); + + it("内置工具抛出异常时应返回错误信息", async () => { + const registry = new ToolRegistry(); + const executor = createExecutor(async () => { + throw new Error("API 请求失败"); + }); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: '{"city":"error"}' }]); + + expect(results).toHaveLength(1); + const parsed = JSON.parse(results[0].result); + expect(parsed.error).toBe("API 请求失败"); + }); + + it("内置工具抛出无 message 的异常时应返回默认错误", async () => { + const registry = new ToolRegistry(); + const executor = createExecutor(async () => { + throw {}; + }); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "{}" }]); + + const parsed = JSON.parse(results[0].result); + expect(parsed.error).toBeDefined(); + }); + + it("内置工具抛出字符串时应正确提取错误消息", async () => { + const registry = new ToolRegistry(); + // 模拟 message 层 throw res.message(直接抛出字符串) + const executor = createExecutor(async () => { + throw "连接超时:无法访问 sandbox"; + }); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "{}" }]); + + const parsed = JSON.parse(results[0].result); + expect(parsed.error).toBe("连接超时:无法访问 sandbox"); + }); + + it("未找到的工具应转发给 scriptCallback", async () => { + const registry = new ToolRegistry(); + const scriptCallback = vi.fn().mockResolvedValue([{ id: "tc_1", result: "script result" }]); + + const results = await registry.execute([{ id: "tc_1", name: "unknown_tool", arguments: "{}" }], scriptCallback); + + expect(scriptCallback).toHaveBeenCalledWith([{ id: "tc_1", name: "unknown_tool", arguments: "{}" }]); + expect(results[0].result).toBe("script result"); + }); + + it("无 scriptCallback 时未找到的工具返回错误", async () => { + const registry = new ToolRegistry(); + + const results = await registry.execute([{ id: "tc_1", name: "unknown_tool", arguments: "{}" }]); + + const parsed = JSON.parse(results[0].result); + expect(parsed.error).toContain("unknown_tool"); + expect(parsed.error).toContain("not found"); + }); + + it("scriptCallback 为 null 时未找到的工具返回错误", async () => { + const registry = new ToolRegistry(); + + const results = await registry.execute([{ id: "tc_1", name: "unknown_tool", arguments: "{}" }], null); + + const parsed = JSON.parse(results[0].result); + expect(parsed.error).toContain("not found"); + }); + + it("应正确分离内置和脚本工具并分别执行", async () => { + const registry = new ToolRegistry(); + const builtinExecutor = createExecutor(async () => "builtin_result"); + registry.registerBuiltin(weatherDef, builtinExecutor); + + const scriptCallback = vi.fn().mockResolvedValue([{ id: "tc_2", result: "script_result" }]); + + const toolCalls: ToolCall[] = [ + { id: "tc_1", name: "get_weather", arguments: '{"city":"杭州"}' }, + { id: "tc_2", name: "custom_tool", arguments: '{"key":"val"}' }, + ]; + + const results = await registry.execute(toolCalls, scriptCallback); + + expect(results).toHaveLength(2); + // 内置工具结果 + expect(results.find((r) => r.id === "tc_1")?.result).toBe("builtin_result"); + // 脚本工具结果 + expect(results.find((r) => r.id === "tc_2")?.result).toBe("script_result"); + // scriptCallback 只收到脚本工具 + expect(scriptCallback).toHaveBeenCalledWith([toolCalls[1]]); + }); + + it("空 toolCalls 数组时应返回空结果", async () => { + const registry = new ToolRegistry(); + const results = await registry.execute([]); + expect(results).toHaveLength(0); + }); + + it("JSON 解析失败时应返回错误", async () => { + const registry = new ToolRegistry(); + const executor = createExecutor(async () => "ok"); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "invalid json{" }]); + + const parsed = JSON.parse(results[0].result); + expect(parsed.error).toBeDefined(); + }); + }); + + describe("附件处理", () => { + function createMockChatRepo() { + return { + saveAttachment: vi.fn().mockResolvedValue(1024), + getAttachment: vi.fn().mockResolvedValue(null), + deleteAttachment: vi.fn().mockResolvedValue(undefined), + deleteAttachments: vi.fn().mockResolvedValue(undefined), + } as unknown as AgentChatRepo; + } + + it("内置工具返回 ToolResultWithAttachments 时应提取附件并保存", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + registry.setChatRepo(mockRepo); + + const structuredResult: ToolResultWithAttachments = { + content: "Screenshot captured.", + attachments: [ + { type: "image", name: "screenshot.jpg", mimeType: "image/jpeg", data: "data:image/jpeg;base64,/9j/abc" }, + ], + }; + const executor = createExecutor(async () => structuredResult); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "{}" }]); + + // 文本结果只包含 content + expect(results[0].result).toBe("Screenshot captured."); + // 附件元数据 + expect(results[0].attachments).toHaveLength(1); + expect(results[0].attachments![0].type).toBe("image"); + expect(results[0].attachments![0].name).toBe("screenshot.jpg"); + expect(results[0].attachments![0].mimeType).toBe("image/jpeg"); + expect(results[0].attachments![0].size).toBe(1024); + expect(typeof results[0].attachments![0].id).toBe("string"); + expect(results[0].attachments![0].id.length).toBeGreaterThan(0); + // 验证 chatRepo.saveAttachment 被调用 + expect(mockRepo.saveAttachment).toHaveBeenCalledTimes(1); + expect(mockRepo.saveAttachment).toHaveBeenCalledWith(expect.any(String), "data:image/jpeg;base64,/9j/abc"); + }); + + it("内置工具返回 ToolResultWithAttachments 含多个附件时应全部保存", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + registry.setChatRepo(mockRepo); + + const structuredResult: ToolResultWithAttachments = { + 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", + }, + ], + }; + const executor = createExecutor(async () => structuredResult); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "{}" }]); + + expect(results[0].attachments).toHaveLength(2); + expect(results[0].attachments![0].name).toBe("img1.png"); + expect(results[0].attachments![1].name).toBe("report.xlsx"); + expect(mockRepo.saveAttachment).toHaveBeenCalledTimes(2); + }); + + it("内置工具返回 Blob 附件时应正确保存", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + registry.setChatRepo(mockRepo); + + 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 }], + }; + const executor = createExecutor(async () => structuredResult); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "{}" }]); + + expect(results[0].attachments).toHaveLength(1); + expect(mockRepo.saveAttachment).toHaveBeenCalledWith(expect.any(String), blob); + }); + + it("内置工具返回普通值时不应产生附件", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + registry.setChatRepo(mockRepo); + + const executor = createExecutor(async () => "plain result"); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "{}" }]); + + expect(results[0].result).toBe("plain result"); + expect(results[0].attachments).toBeUndefined(); + expect(mockRepo.saveAttachment).not.toHaveBeenCalled(); + }); + + it("内置工具返回对象(非附件格式)时不应产生附件", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + registry.setChatRepo(mockRepo); + + // 有 content 但没有 attachments 数组 + const executor = createExecutor(async () => ({ content: "hello", other: 42 })); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "{}" }]); + + expect(results[0].result).toBe('{"content":"hello","other":42}'); + expect(results[0].attachments).toBeUndefined(); + }); + + it("无 chatRepo 时应返回空附件列表", async () => { + const registry = new ToolRegistry(); + // 不调用 setChatRepo + + const structuredResult: ToolResultWithAttachments = { + content: "Screenshot captured.", + attachments: [ + { type: "image", name: "screenshot.jpg", mimeType: "image/jpeg", data: "data:image/jpeg;base64,abc" }, + ], + }; + const executor = createExecutor(async () => structuredResult); + registry.registerBuiltin(weatherDef, executor); + + const results = await registry.execute([{ id: "tc_1", name: "get_weather", arguments: "{}" }]); + + expect(results[0].result).toBe("Screenshot captured."); + // 无 chatRepo 时附件为空数组 + expect(results[0].attachments).toEqual([]); + }); + + it("脚本工具返回结构化附件结果时应提取附件", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + registry.setChatRepo(mockRepo); + + const structuredResult: ToolResultWithAttachments = { + content: "Skill script generated file.", + attachments: [{ type: "file", name: "output.zip", mimeType: "application/zip", data: "base64zipdata" }], + }; + + 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); + + expect(results[0].result).toBe("Skill script generated file."); + expect(results[0].attachments).toHaveLength(1); + expect(results[0].attachments![0].name).toBe("output.zip"); + expect(mockRepo.saveAttachment).toHaveBeenCalledTimes(1); + }); + + it("脚本工具返回普通 JSON 时不应产生附件", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + registry.setChatRepo(mockRepo); + + 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); + + expect(results[0].result).toBe('{"data":"hello"}'); + expect(results[0].attachments).toBeUndefined(); + }); + + it("脚本工具返回非 JSON 字符串时不应产生附件", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + registry.setChatRepo(mockRepo); + + 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); + + expect(results[0].result).toBe("plain text result"); + expect(results[0].attachments).toBeUndefined(); + }); + + it("isToolResultWithAttachments 边界值判断", async () => { + const registry = new ToolRegistry(); + const mockRepo = createMockChatRepo(); + registry.setChatRepo(mockRepo); + + // null + const exec1 = createExecutor(async () => null); + registry.registerBuiltin(weatherDef, exec1); + const r1 = await registry.execute([{ id: "t1", name: "get_weather", arguments: "{}" }]); + expect(r1[0].result).toBe("null"); + expect(r1[0].attachments).toBeUndefined(); + + // content 是数字而不是字符串 + const exec2 = createExecutor(async () => ({ content: 123, attachments: [] })); + registry.registerBuiltin(weatherDef, exec2); + const r2 = await registry.execute([{ id: "t2", name: "get_weather", arguments: "{}" }]); + expect(r2[0].attachments).toBeUndefined(); + + // attachments 不是数组 + const exec3 = createExecutor(async () => ({ content: "ok", attachments: "not-array" })); + registry.registerBuiltin(weatherDef, exec3); + const r3 = await registry.execute([{ id: "t3", name: "get_weather", arguments: "{}" }]); + expect(r3[0].attachments).toBeUndefined(); + }); + }); + + describe("来源追踪 API(register / getSource / listBySource / unregisterBySource)", () => { + it("register 注册工具后 getSource 应返回正确来源", () => { + const registry = new ToolRegistry(); + registry.register( + "mcp", + weatherDef, + createExecutor(async () => "ok") + ); + expect(registry.getSource("get_weather")).toBe("mcp"); + }); + + it("registerBuiltin 注册的工具来源应为 builtin", () => { + const registry = new ToolRegistry(); + registry.registerBuiltin( + weatherDef, + createExecutor(async () => "ok") + ); + expect(registry.getSource("get_weather")).toBe("builtin"); + }); + + it("getSource 查询不存在的工具应返回 undefined", () => { + const registry = new ToolRegistry(); + expect(registry.getSource("nonexistent")).toBeUndefined(); + }); + + it("listBySource 应只返回指定来源的工具名", () => { + const registry = new ToolRegistry(); + registry.register( + "builtin", + weatherDef, + createExecutor(async () => "") + ); + registry.register( + "mcp", + calcDef, + createExecutor(async () => "") + ); + registry.register( + "skill", + { name: "load_skill", description: "加载 skill", parameters: { type: "object", properties: {} } }, + createExecutor(async () => "") + ); + + expect(registry.listBySource("builtin")).toEqual(["get_weather"]); + expect(registry.listBySource("mcp")).toEqual(["calc"]); + expect(registry.listBySource("skill")).toEqual(["load_skill"]); + expect(registry.listBySource("script")).toEqual([]); + }); + + it("unregisterBySource 应批量删除指定来源的工具并返回名称列表", () => { + const registry = new ToolRegistry(); + registry.register( + "mcp", + weatherDef, + createExecutor(async () => "") + ); + registry.register( + "mcp", + calcDef, + createExecutor(async () => "") + ); + registry.register( + "builtin", + { name: "web_fetch", description: "抓取", parameters: { type: "object", properties: {} } }, + createExecutor(async () => "") + ); + + const removed = registry.unregisterBySource("mcp"); + expect(removed).toHaveLength(2); + expect(removed).toContain("get_weather"); + expect(removed).toContain("calc"); + // builtin 工具应保留 + expect(registry.getDefinitions()).toHaveLength(1); + expect(registry.getDefinitions()[0].name).toBe("web_fetch"); + }); + + it("unregisterBySource 无匹配工具时应返回空数组", () => { + const registry = new ToolRegistry(); + registry.register( + "builtin", + weatherDef, + createExecutor(async () => "") + ); + expect(registry.unregisterBySource("mcp")).toEqual([]); + }); + + it("unregister 应按名称删除工具", () => { + const registry = new ToolRegistry(); + registry.register( + "mcp", + weatherDef, + createExecutor(async () => "") + ); + expect(registry.unregister("get_weather")).toBe(true); + expect(registry.getDefinitions()).toHaveLength(0); + }); + + it("unregister 删除不存在的工具应返回 false", () => { + const registry = new ToolRegistry(); + expect(registry.unregister("nonexistent")).toBe(false); + }); + }); + + describe("withScopedTools", () => { + it("fn 正常执行后应清理所有 scoped 工具", async () => { + const registry = new ToolRegistry(); + registry.register( + "builtin", + weatherDef, + createExecutor(async () => "") + ); + + await registry.withScopedTools( + "skill", + [{ definition: calcDef, executor: createExecutor(async () => "42") }], + async () => { + // fn 执行期间 scoped 工具应存在 + expect(registry.getSource("calc")).toBe("skill"); + expect(registry.getDefinitions()).toHaveLength(2); + } + ); + + // fn 结束后 scoped 工具应被清理 + expect(registry.getSource("calc")).toBeUndefined(); + expect(registry.getDefinitions()).toHaveLength(1); + }); + + it("fn 抛出异常时也应清理 scoped 工具(finally 保证)", async () => { + const registry = new ToolRegistry(); + + await expect( + registry.withScopedTools( + "skill", + [{ definition: calcDef, executor: createExecutor(async () => "42") }], + async () => { + throw new Error("测试异常"); + } + ) + ).rejects.toThrow("测试异常"); + + // 即使抛出,scoped 工具也应被清理 + expect(registry.getSource("calc")).toBeUndefined(); + }); + + it("withScopedTools 应返回 fn 的返回值", async () => { + const registry = new ToolRegistry(); + + const result = await registry.withScopedTools( + "skill", + [{ definition: calcDef, executor: createExecutor(async () => "") }], + async () => "scoped_result" + ); + + expect(result).toBe("scoped_result"); + }); + + it("多个 scoped 工具应全部被清理", async () => { + const registry = new ToolRegistry(); + const toolA: ToolDefinition = { + name: "tool_a", + description: "A", + parameters: { type: "object", properties: {} }, + }; + const toolB: ToolDefinition = { + name: "tool_b", + description: "B", + parameters: { type: "object", properties: {} }, + }; + + await registry.withScopedTools( + "skill", + [ + { definition: toolA, executor: createExecutor(async () => "") }, + { definition: toolB, executor: createExecutor(async () => "") }, + ], + async () => { + expect(registry.listBySource("skill")).toHaveLength(2); + } + ); + + expect(registry.listBySource("skill")).toHaveLength(0); + }); + }); +}); diff --git a/src/app/service/agent/core/tool_registry.ts b/src/app/service/agent/core/tool_registry.ts new file mode 100644 index 000000000..9008254b3 --- /dev/null +++ b/src/app/service/agent/core/tool_registry.ts @@ -0,0 +1,253 @@ +import type { Attachment, SubAgentDetails, ToolCall, ToolDefinition, ToolResultWithAttachments } from "./types"; +import type { AgentChatRepo } from "@App/app/repo/agent_chat"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { getExtFromMime } from "./content_utils"; + +// 工具执行器接口 +export interface ToolExecutor { + execute(args: Record): Promise; +} + +// 工具来源分类 +export type ToolSource = "builtin" | "mcp" | "skill" | "script"; + +// 工具条目(带来源追踪) +interface ToolEntry { + definition: ToolDefinition; + executor: ToolExecutor; + source: ToolSource; +} + +// 脚本工具回调类型:将 tool calls 发送到 Sandbox 执行 +export type ScriptToolCallback = (toolCalls: ToolCall[]) => Promise>; + +// 工具执行结果(可能含附件和子代理详情) +export type ToolExecuteResult = { + id: string; + result: string; + attachments?: Attachment[]; + subAgentDetails?: SubAgentDetails; +}; + +// 从异常中提取错误消息(兼容 Error 对象和直接 throw 的字符串) +function extractErrorMessage(e: unknown): string { + if (e instanceof Error) return e.message || e.toString(); + if (typeof e === "string") return e; + return String(e) || "Tool execution failed"; +} + +// 判断返回值是否是带附件的结构化结果 +function isToolResultWithAttachments(value: unknown): value is ToolResultWithAttachments { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return typeof obj.content === "string" && Array.isArray(obj.attachments); +} + +// 判断返回值是否包含子代理详情 +function isToolResultWithSubAgent(value: unknown): value is { content: string; subAgentDetails: SubAgentDetails } { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return typeof obj.content === "string" && typeof obj.subAgentDetails === "object" && obj.subAgentDetails !== null; +} + +// 工具注册表,管理内置工具和脚本工具的统一执行 +export class ToolRegistry { + private tools = new Map(); + private chatRepo?: AgentChatRepo; + + // 注入 AgentChatRepo 用于保存附件 + setChatRepo(repo: AgentChatRepo): void { + this.chatRepo = repo; + } + + // 注册工具(带来源追踪) + register(source: ToolSource, definition: ToolDefinition, executor: ToolExecutor): void { + this.tools.set(definition.name, { definition, executor, source }); + } + + // 注册内置工具(兼容旧 API,等价于 register("builtin", ...)) + registerBuiltin(definition: ToolDefinition, executor: ToolExecutor): void { + this.register("builtin", definition, executor); + } + + // 按名称注销工具 + unregister(name: string): boolean { + return this.tools.delete(name); + } + + // 注销内置工具(兼容旧 API,等价于 unregister(name)) + unregisterBuiltin(name: string): boolean { + return this.unregister(name); + } + + // 批量注销某个来源的所有工具(用于 MCP server 断开时清理) + unregisterBySource(source: ToolSource): string[] { + const removed: string[] = []; + for (const [name, entry] of this.tools.entries()) { + if (entry.source === source) { + this.tools.delete(name); + removed.push(name); + } + } + return removed; + } + + // 获取某个工具的来源信息(调试用) + getSource(name: string): ToolSource | undefined { + return this.tools.get(name)?.source; + } + + // 列出某来源的所有工具名 + listBySource(source: ToolSource): string[] { + const names: string[] = []; + for (const [name, entry] of this.tools.entries()) { + if (entry.source === source) names.push(name); + } + return names; + } + + // 在临时 scoped 工具范围内执行 fn,保证 finally 清理(即使 fn 抛出) + // 注意:仍是共享 Map,并发调用同名工具时会互相覆盖;真正的会话隔离需重构为每会话独立实例 + async withScopedTools( + source: ToolSource, + scopedTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>, + fn: () => Promise + ): Promise { + for (const t of scopedTools) { + this.register(source, t.definition, t.executor); + } + try { + return await fn(); + } finally { + for (const t of scopedTools) { + this.unregister(t.definition.name); + } + } + } + + // 获取所有工具定义(内置 + 额外的脚本工具),发送给 LLM + getDefinitions(extraTools?: ToolDefinition[]): ToolDefinition[] { + const definitions: ToolDefinition[] = []; + for (const { definition } of this.tools.values()) { + definitions.push(definition); + } + if (extraTools) { + definitions.push(...extraTools); + } + return definitions; + } + + // 执行工具调用:先查注册工具,未找到则交给脚本回调 + async execute(toolCalls: ToolCall[], scriptCallback?: ScriptToolCallback | null): Promise { + const builtinCalls: ToolCall[] = []; + const scriptCalls: ToolCall[] = []; + + for (const tc of toolCalls) { + if (this.tools.has(tc.name)) { + builtinCalls.push(tc); + } else { + scriptCalls.push(tc); + } + } + + const results: ToolExecuteResult[] = []; + + // 并行执行注册工具 + const builtinResults = await Promise.all( + builtinCalls.map(async (tc): Promise => { + const tool = this.tools.get(tc.name)!; + try { + let args: Record = {}; + if (tc.arguments) { + args = JSON.parse(tc.arguments); + } + const rawResult = await tool.executor.execute(args); + + // 检查是否带附件或子代理详情 + if (isToolResultWithAttachments(rawResult)) { + const attachments = await this.saveAttachments(rawResult.attachments); + return { id: tc.id, result: rawResult.content, attachments }; + } else if (isToolResultWithSubAgent(rawResult)) { + return { id: tc.id, result: rawResult.content, subAgentDetails: rawResult.subAgentDetails }; + } else { + return { id: tc.id, result: typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult) }; + } + } catch (e: any) { + console.error(`[ToolRegistry] tool "${tc.name}" execution failed:`, e); + return { id: tc.id, result: JSON.stringify({ error: extractErrorMessage(e) }) }; + } + }) + ); + results.push(...builtinResults); + + // 执行脚本工具 + if (scriptCalls.length > 0) { + if (scriptCallback) { + const scriptResults = await scriptCallback(scriptCalls); + // 脚本工具也可能返回带附件的结构化结果 + for (const sr of scriptResults) { + try { + const parsed = JSON.parse(sr.result); + if (isToolResultWithAttachments(parsed)) { + const attachments = await this.saveAttachments(parsed.attachments); + results.push({ id: sr.id, result: parsed.content, attachments }); + continue; + } + } catch { + // 不是 JSON 或不是结构化结果,按原始字符串处理 + } + results.push({ id: sr.id, result: sr.result }); + } + } else { + // 没有脚本回调,返回错误并列出可用工具名,引导 LLM 自我纠正 + const availableNames = Array.from(this.tools.keys()); + for (const tc of scriptCalls) { + const hint = availableNames.includes("execute_skill_script") + ? ` If "${tc.name}" is a skill script, use the "execute_skill_script" tool instead.` + : ""; + results.push({ + id: tc.id, + result: JSON.stringify({ + error: `Tool "${tc.name}" not found. Available tools: [${availableNames.join(", ")}].${hint}`, + }), + }); + } + } + } + + return results; + } + + // 保存附件数据到 OPFS,返回 Attachment 元数据 + private async saveAttachments(attachmentDataList: ToolResultWithAttachments["attachments"]): Promise { + if (!this.chatRepo || attachmentDataList.length === 0) return []; + + const attachments: Attachment[] = []; + for (const ad of attachmentDataList) { + if (!ad.data) { + // 无 data 的附件是已保存的引用(如 skill script 返回的 imageBlock),直接透传元数据 + if ("attachmentId" in ad && (ad as any).attachmentId) { + attachments.push({ + id: (ad as any).attachmentId, + type: ad.type, + name: ad.name, + mimeType: ad.mimeType, + size: (ad as any).size, + }); + } + continue; + } + const ext = getExtFromMime(ad.mimeType); + const id = `${uuidv4()}.${ext}`; + const size = await this.chatRepo.saveAttachment(id, ad.data); + attachments.push({ + id, + type: ad.type, + name: ad.name, + mimeType: ad.mimeType, + size, + }); + } + return attachments; + } +} diff --git a/src/app/service/agent/core/tools/ask_user.test.ts b/src/app/service/agent/core/tools/ask_user.test.ts new file mode 100644 index 000000000..6c3b77645 --- /dev/null +++ b/src/app/service/agent/core/tools/ask_user.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi } from "vitest"; +import { createAskUserTool } from "./ask_user"; +import type { ChatStreamEvent } from "@App/app/service/agent/core/types"; + +describe("ask_user", () => { + it("should send ask_user event and resolve when answer is provided", async () => { + const events: ChatStreamEvent[] = []; + const sendEvent = (event: ChatStreamEvent) => events.push(event); + const resolvers = new Map void>(); + + const { executor } = createAskUserTool(sendEvent, resolvers); + + // Start execution (will block until resolved) + const resultPromise = executor.execute({ question: "What color?" }); + + // Verify event was sent + expect(events).toHaveLength(1); + expect(events[0].type).toBe("ask_user"); + const askEvent = events[0] as Extract; + expect(askEvent.question).toBe("What color?"); + + // Resolve the question + expect(resolvers.size).toBe(1); + const [_askId, resolve] = Array.from(resolvers.entries())[0]; + resolve("Blue"); + + const result = await resultPromise; + expect(JSON.parse(result as string)).toEqual({ answer: "Blue" }); + expect(resolvers.size).toBe(0); + }); + + it("should throw if question is missing", async () => { + const sendEvent = vi.fn(); + const resolvers = new Map void>(); + const { executor } = createAskUserTool(sendEvent, resolvers); + + await expect(executor.execute({})).rejects.toThrow("question is required"); + }); + + it("should resolve with timeout reason after 5 minutes", async () => { + vi.useFakeTimers(); + const sendEvent = vi.fn(); + const resolvers = new Map void>(); + + const { executor } = createAskUserTool(sendEvent, resolvers); + const resultPromise = executor.execute({ question: "Waiting..." }); + + // Advance time past timeout + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + + const result = JSON.parse((await resultPromise) as string); + expect(result).toEqual({ answer: null, reason: "timeout" }); + expect(resolvers.size).toBe(0); + + vi.useRealTimers(); + }); + + it("should generate unique ask IDs", async () => { + const events: ChatStreamEvent[] = []; + const sendEvent = (event: ChatStreamEvent) => events.push(event); + const resolvers = new Map void>(); + + const { executor } = createAskUserTool(sendEvent, resolvers); + + // Start two asks + const p1 = executor.execute({ question: "Q1" }); + const p2 = executor.execute({ question: "Q2" }); + + expect(events).toHaveLength(2); + const id1 = (events[0] as Extract).id; + const id2 = (events[1] as Extract).id; + expect(id1).not.toBe(id2); + + // Resolve both + for (const [_id, resolve] of resolvers) { + resolve("answer"); + } + await Promise.all([p1, p2]); + }); + + it("should send options in ask_user event", async () => { + const events: ChatStreamEvent[] = []; + const sendEvent = (event: ChatStreamEvent) => events.push(event); + const resolvers = new Map void>(); + + const { executor } = createAskUserTool(sendEvent, resolvers); + + const resultPromise = executor.execute({ + question: "Pick a color", + options: ["Red", "Blue", "Green"], + }); + + expect(events).toHaveLength(1); + const askEvent = events[0] as Extract; + expect(askEvent.options).toEqual(["Red", "Blue", "Green"]); + expect(askEvent.multiple).toBeUndefined(); + + // Resolve + const [_id, resolve] = Array.from(resolvers.entries())[0]; + resolve("Blue"); + const result = JSON.parse((await resultPromise) as string); + expect(result).toEqual({ answer: "Blue" }); + }); + + it("should send multiple flag in ask_user event", async () => { + const events: ChatStreamEvent[] = []; + const sendEvent = (event: ChatStreamEvent) => events.push(event); + const resolvers = new Map void>(); + + const { executor } = createAskUserTool(sendEvent, resolvers); + + const resultPromise = executor.execute({ + question: "Select languages", + options: ["JavaScript", "Python", "Rust"], + multiple: true, + }); + + expect(events).toHaveLength(1); + const askEvent = events[0] as Extract; + expect(askEvent.options).toEqual(["JavaScript", "Python", "Rust"]); + expect(askEvent.multiple).toBe(true); + + // Resolve with multiple selections + const [_id, resolve] = Array.from(resolvers.entries())[0]; + resolve(JSON.stringify(["JavaScript", "Rust"])); + const result = JSON.parse((await resultPromise) as string); + expect(result).toEqual({ answer: '["JavaScript","Rust"]' }); + }); +}); diff --git a/src/app/service/agent/core/tools/ask_user.ts b/src/app/service/agent/core/tools/ask_user.ts new file mode 100644 index 000000000..7a516a543 --- /dev/null +++ b/src/app/service/agent/core/tools/ask_user.ts @@ -0,0 +1,69 @@ +import type { ToolDefinition, ChatStreamEvent } from "@App/app/service/agent/core/types"; +import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; + +export const ASK_USER_DEFINITION: ToolDefinition = { + name: "ask_user", + description: + "Ask the user a question and wait for their response. " + + "Text response only (no image support). Times out after 5 minutes. " + + "The user can always type a custom response even when options are provided.", + parameters: { + type: "object", + properties: { + question: { type: "string", description: "The question to ask the user" }, + options: { + type: "array", + items: { type: "string" }, + description: "List of choices. User selects from these but can also type a custom response.", + }, + multiple: { + type: "boolean", + description: "Allow selecting multiple options (default: false).", + }, + }, + required: ["question"], + }, +}; + +// 5 分钟超时 +const ASK_USER_TIMEOUT_MS = 5 * 60 * 1000; + +export function createAskUserTool( + sendEvent: (event: ChatStreamEvent) => void, + resolvers: Map void> +): { definition: ToolDefinition; executor: ToolExecutor } { + let askCounter = 0; + + const executor: ToolExecutor = { + execute: async (args: Record) => { + const question = args.question as string; + if (!question) { + throw new Error("question is required"); + } + + const options = args.options as string[] | undefined; + const multiple = args.multiple as boolean | undefined; + + const askId = `ask_${Date.now()}_${++askCounter}`; + + // 通知 UI 显示提问 + sendEvent({ type: "ask_user", id: askId, question, options, multiple }); + + // 等待用户回复 + return new Promise((resolve) => { + const timer = setTimeout(() => { + resolvers.delete(askId); + resolve(JSON.stringify({ answer: null, reason: "timeout" })); + }, ASK_USER_TIMEOUT_MS); + + resolvers.set(askId, (answer: string) => { + clearTimeout(timer); + resolvers.delete(askId); + resolve(JSON.stringify({ answer })); + }); + }); + }, + }; + + return { definition: ASK_USER_DEFINITION, executor }; +} diff --git a/src/app/service/agent/core/tools/execute_script.test.ts b/src/app/service/agent/core/tools/execute_script.test.ts new file mode 100644 index 000000000..079b8b09a --- /dev/null +++ b/src/app/service/agent/core/tools/execute_script.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi } from "vitest"; +import { createExecuteScriptTool, type ExecuteScriptDeps } from "./execute_script"; + +function makeDeps(overrides?: Partial): ExecuteScriptDeps { + return { + executeInPage: vi.fn().mockResolvedValue({ result: "page_result", tabId: 1 }), + executeInSandbox: vi.fn().mockResolvedValue("sandbox_result"), + ...overrides, + }; +} + +describe("execute_script 工具", () => { + describe("参数校验", () => { + it.concurrent("缺少 code 应抛错", async () => { + const deps = makeDeps(); + const { executor } = createExecuteScriptTool(deps); + await expect(executor.execute({ target: "page" })).rejects.toThrow("code is required"); + }); + + it.concurrent("缺少 target 应抛错", async () => { + const deps = makeDeps(); + const { executor } = createExecuteScriptTool(deps); + await expect(executor.execute({ code: "return 1" })).rejects.toThrow("target is required"); + }); + + it.concurrent("无效 target 应抛错", async () => { + const deps = makeDeps(); + const { executor } = createExecuteScriptTool(deps); + await expect(executor.execute({ code: "return 1", target: "invalid" })).rejects.toThrow( + "Invalid target: invalid" + ); + }); + }); + + describe("page 模式", () => { + it.concurrent("应调用 executeInPage 并返回结果", async () => { + const mockExecuteInPage = vi.fn().mockResolvedValue({ result: { count: 5 }, tabId: 42 }); + const deps = makeDeps({ executeInPage: mockExecuteInPage }); + const { executor } = createExecuteScriptTool(deps); + + const result = await executor.execute({ code: "return document.title", target: "page" }); + const parsed = JSON.parse(result as string); + + expect(parsed).toEqual({ result: { count: 5 }, target: "page", tab_id: 42 }); + expect(mockExecuteInPage).toHaveBeenCalledWith("return document.title", { + tabId: undefined, + }); + }); + + it.concurrent("应传递 tab_id 参数", async () => { + const mockExecuteInPage = vi.fn().mockResolvedValue({ result: null, tabId: 10 }); + const deps = makeDeps({ executeInPage: mockExecuteInPage }); + const { executor } = createExecuteScriptTool(deps); + + await executor.execute({ code: "return 1", target: "page", tab_id: 10 }); + + expect(mockExecuteInPage).toHaveBeenCalledWith("return 1", { tabId: 10 }); + }); + + it.concurrent("返回值为 undefined 时应转为 null", async () => { + const mockExecuteInPage = vi.fn().mockResolvedValue({ result: undefined, tabId: 1 }); + const deps = makeDeps({ executeInPage: mockExecuteInPage }); + const { executor } = createExecuteScriptTool(deps); + + const result = await executor.execute({ code: "void 0", target: "page" }); + const parsed = JSON.parse(result as string); + + expect(parsed.result).toBe(null); + }); + }); + + describe("sandbox 模式", () => { + it.concurrent("应调用 executeInSandbox 并返回结果", async () => { + const mockExecuteInSandbox = vi.fn().mockResolvedValue({ sum: 42 }); + const deps = makeDeps({ executeInSandbox: mockExecuteInSandbox }); + const { executor } = createExecuteScriptTool(deps); + + const result = await executor.execute({ code: "return 1+2", target: "sandbox" }); + const parsed = JSON.parse(result as string); + + expect(parsed).toEqual({ result: { sum: 42 }, target: "sandbox" }); + expect(parsed).not.toHaveProperty("tab_id"); + expect(mockExecuteInSandbox).toHaveBeenCalledWith("return 1+2"); + }); + + it.concurrent("返回值为 undefined 时应转为 null", async () => { + const mockExecuteInSandbox = vi.fn().mockResolvedValue(undefined); + const deps = makeDeps({ executeInSandbox: mockExecuteInSandbox }); + const { executor } = createExecuteScriptTool(deps); + + const result = await executor.execute({ code: "void 0", target: "sandbox" }); + const parsed = JSON.parse(result as string); + + expect(parsed.result).toBe(null); + }); + }); + + describe("超时", () => { + it.concurrent("page 模式超时应报错", async () => { + const mockExecuteInPage = vi.fn().mockReturnValue(new Promise(() => {})); + const deps = makeDeps({ executeInPage: mockExecuteInPage, timeoutMs: 50 }); + const { executor } = createExecuteScriptTool(deps); + + await expect(executor.execute({ code: "while(true){}", target: "page" })).rejects.toThrow( + "execute_script timed out after 30s" + ); + }); + + it.concurrent("sandbox 模式超时应报错", async () => { + const mockExecuteInSandbox = vi.fn().mockReturnValue(new Promise(() => {})); + const deps = makeDeps({ executeInSandbox: mockExecuteInSandbox, timeoutMs: 50 }); + const { executor } = createExecuteScriptTool(deps); + + await expect(executor.execute({ code: "while(true){}", target: "sandbox" })).rejects.toThrow( + "execute_script timed out after 30s" + ); + }); + }); + + describe("错误传播", () => { + it.concurrent("page 模式执行错误应传播", async () => { + const mockExecuteInPage = vi.fn().mockRejectedValue(new Error("No active tab found")); + const deps = makeDeps({ executeInPage: mockExecuteInPage }); + const { executor } = createExecuteScriptTool(deps); + + await expect(executor.execute({ code: "return 1", target: "page" })).rejects.toThrow("No active tab found"); + }); + + it.concurrent("sandbox 模式执行错误应传播", async () => { + const mockExecuteInSandbox = vi.fn().mockRejectedValue(new Error("Sandbox execution failed")); + const deps = makeDeps({ executeInSandbox: mockExecuteInSandbox }); + const { executor } = createExecuteScriptTool(deps); + + await expect(executor.execute({ code: "throw new Error()", target: "sandbox" })).rejects.toThrow( + "Sandbox execution failed" + ); + }); + }); +}); diff --git a/src/app/service/agent/core/tools/execute_script.ts b/src/app/service/agent/core/tools/execute_script.ts new file mode 100644 index 000000000..fc4a5c6e6 --- /dev/null +++ b/src/app/service/agent/core/tools/execute_script.ts @@ -0,0 +1,80 @@ +import type { ToolDefinition } from "@App/app/service/agent/core/types"; +import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; +import { withTimeout } from "@App/pkg/utils/with_timeout"; + +export const EXECUTE_SCRIPT_DEFINITION: ToolDefinition = { + name: "execute_script", + description: + "Execute JavaScript code. " + + "target='page': run in a browser tab (MAIN world) with full DOM access, shares page's window/globals — can access page JS variables and call page functions. Cannot access extension blob URLs. " + + "target='sandbox': isolated computation environment, no DOM. " + + "Use `return` to return a value. Timeout: 30 seconds.", + parameters: { + type: "object", + properties: { + code: { type: "string", description: "JavaScript code to execute. Use `return` to return a value." }, + target: { + type: "string", + enum: ["page", "sandbox"], + description: "'page' runs in a tab, 'sandbox' runs in isolated env.", + }, + tab_id: { + type: "number", + description: "Target tab ID for page execution. Defaults to active tab. Ignored for sandbox.", + }, + }, + required: ["code", "target"], + }, +}; + +const EXECUTE_SCRIPT_TIMEOUT_MS = 30_000; + +export type ExecuteScriptDeps = { + executeInPage: (code: string, options?: { tabId?: number }) => Promise<{ result: unknown; tabId: number }>; + executeInSandbox: (code: string) => Promise; + timeoutMs?: number; // 可选超时(ms),默认 30s,测试用 +}; + +export function createExecuteScriptTool(deps: ExecuteScriptDeps): { + definition: ToolDefinition; + executor: ToolExecutor; +} { + const timeoutMs = deps.timeoutMs ?? EXECUTE_SCRIPT_TIMEOUT_MS; + + const executor: ToolExecutor = { + execute: async (args: Record) => { + const code = args.code as string | undefined; + if (!code) { + throw new Error("code is required"); + } + + const target = args.target as string | undefined; + if (!target) { + throw new Error("target is required"); + } + if (target !== "page" && target !== "sandbox") { + throw new Error(`Invalid target: ${target}. Must be 'page' or 'sandbox'.`); + } + + if (target === "page") { + const tabId = args.tab_id as number | undefined; + const { result, tabId: actualTabId } = await withTimeout( + deps.executeInPage(code, { tabId }), + timeoutMs, + () => new Error("execute_script timed out after 30s") + ); + return JSON.stringify({ result: result ?? null, target: "page", tab_id: actualTabId }); + } + + // sandbox + const result = await withTimeout( + deps.executeInSandbox(code), + timeoutMs, + () => new Error("execute_script timed out after 30s") + ); + return JSON.stringify({ result: result ?? null, target: "sandbox" }); + }, + }; + + return { definition: EXECUTE_SCRIPT_DEFINITION, executor }; +} diff --git a/src/app/service/agent/core/tools/opfs_tools.test.ts b/src/app/service/agent/core/tools/opfs_tools.test.ts new file mode 100644 index 000000000..0b1da86fb --- /dev/null +++ b/src/app/service/agent/core/tools/opfs_tools.test.ts @@ -0,0 +1,471 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createOPFSTools, sanitizePath, setCreateBlobUrlFn, guessMimeType } from "./opfs_tools"; +import { isText } from "@App/pkg/utils/istextorbinary"; + +// ---- In-memory OPFS mock ---- + +type FSNode = { kind: "file"; content: string | Uint8Array } | { kind: "directory"; children: Map }; + +function createMockFS() { + const root: FSNode = { kind: "directory", children: new Map() }; + + function navigate(path: string[]): FSNode { + let node = root; + for (const seg of path) { + if (node.kind !== "directory") throw new DOMException("Not a directory", "TypeMismatchError"); + const child = node.children.get(seg); + if (!child) throw new DOMException(`"${seg}" not found`, "NotFoundError"); + node = child; + } + return node; + } + + function makeDirectoryHandle(node: FSNode & { kind: "directory" }, name = ""): FileSystemDirectoryHandle { + const handle: any = { + kind: "directory", + name, + getDirectoryHandle(childName: string, opts?: { create?: boolean }) { + let child = node.children.get(childName); + if (!child) { + if (opts?.create) { + child = { kind: "directory", children: new Map() }; + node.children.set(childName, child); + } else { + throw new DOMException(`"${childName}" not found`, "NotFoundError"); + } + } + if (child.kind !== "directory") throw new DOMException("Not a directory", "TypeMismatchError"); + return makeDirectoryHandle(child, childName); + }, + getFileHandle(childName: string, opts?: { create?: boolean }) { + let child = node.children.get(childName); + if (!child) { + if (opts?.create) { + child = { kind: "file", content: "" }; + node.children.set(childName, child); + } else { + throw new DOMException(`"${childName}" not found`, "NotFoundError"); + } + } + if (child.kind !== "file") throw new DOMException("Not a file", "TypeMismatchError"); + return makeFileHandle(child, childName); + }, + removeEntry(childName: string) { + if (!node.children.has(childName)) { + throw new DOMException(`"${childName}" not found`, "NotFoundError"); + } + node.children.delete(childName); + }, + async *[Symbol.asyncIterator]() { + for (const [n, c] of node.children) { + if (c.kind === "file") { + yield [n, makeFileHandle(c, n)]; + } else { + yield [n, makeDirectoryHandle(c, n)]; + } + } + }, + }; + return handle; + } + + function makeFileHandle(node: FSNode & { kind: "file" }, name: string): FileSystemFileHandle { + const handle: any = { + kind: "file", + name, + async getFile() { + return new Blob([node.content as BlobPart]); + }, + async createWritable() { + const chunks: (string | Uint8Array)[] = []; + return { + async write(data: string | Uint8Array) { + chunks.push(data); + }, + async close() { + // 合并所有 chunk,如果全是 string 就存 string,否则存 Uint8Array + if (chunks.every((c) => typeof c === "string")) { + node.content = chunks.join(""); + } else { + const blob = new Blob(chunks as BlobPart[]); + node.content = new Uint8Array(await blob.arrayBuffer()); + } + }, + }; + }, + }; + return handle; + } + + return { + root, + navigate, + rootHandle: makeDirectoryHandle(root, ""), + }; +} + +// Extend Blob with text() for vitest (jsdom may not have it) +if (!Blob.prototype.text) { + Blob.prototype.text = async function () { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.readAsText(this); + }); + }; +} + +describe("sanitizePath", () => { + it("should strip leading slashes", () => { + expect(sanitizePath("/foo/bar.txt")).toBe("foo/bar.txt"); + expect(sanitizePath("///a/b")).toBe("a/b"); + }); + + it("should reject .. segments", () => { + expect(() => sanitizePath("../etc/passwd")).toThrow('".." is not allowed'); + expect(() => sanitizePath("foo/../../bar")).toThrow('".." is not allowed'); + }); + + it("should handle normal paths", () => { + expect(sanitizePath("notes/todo.txt")).toBe("notes/todo.txt"); + expect(sanitizePath("file.txt")).toBe("file.txt"); + }); + + it("should collapse empty segments", () => { + expect(sanitizePath("a//b///c")).toBe("a/b/c"); + }); +}); + +describe("guessMimeType", () => { + it("常见文本扩展名返回正确 MIME", () => { + expect(guessMimeType("readme.md")).toBe("text/markdown"); + expect(guessMimeType("data.csv")).toBe("text/csv"); + expect(guessMimeType("config.yaml")).toBe("text/yaml"); + expect(guessMimeType("config.yml")).toBe("text/yaml"); + expect(guessMimeType("index.html")).toBe("text/html"); + expect(guessMimeType("index.htm")).toBe("text/html"); + expect(guessMimeType("style.css")).toBe("text/css"); + expect(guessMimeType("data.xml")).toBe("text/xml"); + expect(guessMimeType("data.json")).toBe("application/json"); + expect(guessMimeType("app.js")).toBe("application/javascript"); + expect(guessMimeType("lib.mjs")).toBe("application/javascript"); + }); + + it("常见二进制扩展名返回正确 MIME", () => { + expect(guessMimeType("photo.png")).toBe("image/png"); + expect(guessMimeType("photo.jpg")).toBe("image/jpeg"); + expect(guessMimeType("song.mp3")).toBe("audio/mpeg"); + expect(guessMimeType("video.mp4")).toBe("video/mp4"); + expect(guessMimeType("doc.pdf")).toBe("application/pdf"); + expect(guessMimeType("archive.zip")).toBe("application/zip"); + }); + + it("未知扩展名返回 octet-stream", () => { + expect(guessMimeType("data.xyz")).toBe("application/octet-stream"); + expect(guessMimeType("Makefile")).toBe("application/octet-stream"); + expect(guessMimeType("file.rar")).toBe("application/octet-stream"); + }); +}); + +describe("isText(内容检测)", () => { + it("UTF-8 文本内容被识别为文本", () => { + const textContent = new TextEncoder().encode("Hello, world!\nThis is a text file."); + expect(isText(textContent)).toBe(true); + }); + + it("中文 UTF-8 文本被识别为文本", () => { + const textContent = new TextEncoder().encode("你好,世界!这是一个文本文件。"); + expect(isText(textContent)).toBe(true); + }); + + it("含 null 字节的内容被识别为二进制", () => { + // isText 检测 charCode <= 8 为二进制(null byte = 0x00) + const binaryContent = new Uint8Array([0x00, 0x01, 0x50, 0x4e, 0x47, 0xff, 0xfe, 0x00]); + expect(isText(binaryContent)).toBe(false); + }); + + it("空内容返回 false", () => { + expect(isText(null)).toBe(false); + expect(isText(undefined)).toBe(false); + }); +}); + +describe("opfs_tools", () => { + let mockFS: ReturnType; + + beforeEach(() => { + mockFS = createMockFS(); + vi.stubGlobal("navigator", { + ...globalThis.navigator, + storage: { + getDirectory: vi.fn().mockResolvedValue(mockFS.rootHandle), + }, + }); + // opfs_read 读取二进制文件时需要 createBlobUrlFn 生成 blob URL + setCreateBlobUrlFn(async () => "blob:mock-url"); + }); + + function getTool(name: string) { + const { tools } = createOPFSTools(); + return tools.find((t) => t.definition.name === name)!; + } + + it("should create 4 tools", () => { + const { tools } = createOPFSTools(); + expect(tools).toHaveLength(4); + expect(tools.map((t) => t.definition.name)).toEqual(["opfs_write", "opfs_read", "opfs_list", "opfs_delete"]); + }); + + describe("opfs_write + opfs_read", () => { + it("should write and read a text file", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + const writeResult = JSON.parse( + (await write.executor.execute({ path: "hello.txt", content: "Hello!" })) as string + ); + expect(writeResult.path).toBe("hello.txt"); + expect(writeResult.size).toBe(6); + + const readResult = JSON.parse((await read.executor.execute({ path: "hello.txt" })) as string); + expect(readResult.path).toBe("hello.txt"); + expect(readResult.type).toBe("text"); + expect(readResult.content).toBe("Hello!"); + }); + + it("should create nested directories automatically", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + await write.executor.execute({ path: "a/b/c.txt", content: "deep" }); + const result = JSON.parse((await read.executor.execute({ path: "a/b/c.txt" })) as string); + expect(result.type).toBe("text"); + expect(result.content).toBe("deep"); + }); + + it("should overwrite existing file", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + await write.executor.execute({ path: "f.txt", content: "v1" }); + await write.executor.execute({ path: "f.txt", content: "v2" }); + const result = JSON.parse((await read.executor.execute({ path: "f.txt" })) as string); + expect(result.type).toBe("text"); + expect(result.content).toBe("v2"); + }); + + it("should strip leading slashes from path", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + await write.executor.execute({ path: "/leading.txt", content: "ok" }); + const result = JSON.parse((await read.executor.execute({ path: "leading.txt" })) as string); + expect(result.type).toBe("text"); + expect(result.content).toBe("ok"); + }); + + it("should reject .. in path", async () => { + const write = getTool("opfs_write"); + await expect(write.executor.execute({ path: "../escape.txt", content: "bad" })).rejects.toThrow( + '".." is not allowed' + ); + }); + }); + + describe("opfs_read 文本读取", () => { + it("should return text content for text files", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + await write.executor.execute({ path: "hello.txt", content: "line1\nline2\nline3" }); + const result = JSON.parse((await read.executor.execute({ path: "hello.txt" })) as string); + expect(result.type).toBe("text"); + expect(result.content).toBe("line1\nline2\nline3"); + expect(result.totalLines).toBe(3); + expect(result.startLine).toBe(1); + expect(result.endLine).toBe(3); + expect(result.blobUrl).toBeUndefined(); + }); + + it("should return text content for json files", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + await write.executor.execute({ path: "data.json", content: '{"key":"value"}' }); + const result = JSON.parse((await read.executor.execute({ path: "data.json" })) as string); + expect(result.type).toBe("text"); + expect(result.content).toBe('{"key":"value"}'); + }); + + it("should return blob URL for binary files (png)", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + // 先通过 write 创建文件(建立 workspace 目录结构),再替换为二进制内容 + await write.executor.execute({ path: "image.png", content: "placeholder" }); + const wsDir = mockFS.root.children.get("agents") as FSNode & { kind: "directory" }; + const workspace = wsDir.children.get("workspace") as FSNode & { kind: "directory" }; + workspace.children.set("image.png", { + kind: "file", + content: new Uint8Array([0x89, 0x50, 0x00, 0x47, 0x00, 0x0a, 0x00, 0x0a]), + }); + + const result = JSON.parse((await read.executor.execute({ path: "image.png" })) as string); + expect(result.type).toBe("binary"); + expect(result.blobUrl).toBe("blob:mock-url"); + expect(result.content).toBeUndefined(); + }); + + it("mode=blob 时文本文件也返回 blob URL", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + await write.executor.execute({ path: "readme.txt", content: "hello" }); + const result = JSON.parse((await read.executor.execute({ path: "readme.txt", mode: "blob" })) as string); + expect(result.type).toBe("binary"); + expect(result.blobUrl).toBe("blob:mock-url"); + expect(result.content).toBeUndefined(); + }); + + it("mode=text 时二进制内容也强制返回文本", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + // 先创建文件,再替换为二进制内容 + await write.executor.execute({ path: "data.bin", content: "placeholder" }); + const wsDir = mockFS.root.children.get("agents") as FSNode & { kind: "directory" }; + const workspace = wsDir.children.get("workspace") as FSNode & { kind: "directory" }; + workspace.children.set("data.bin", { + kind: "file", + content: new Uint8Array([0x48, 0x00, 0x65, 0x00, 0x6c, 0x00]), + }); + + // auto 模式下内容检测为二进制,返回 blob + const blobResult = JSON.parse((await read.executor.execute({ path: "data.bin" })) as string); + expect(blobResult.type).toBe("binary"); + + // mode=text 强制文本读取 + const textResult = JSON.parse((await read.executor.execute({ path: "data.bin", mode: "text" })) as string); + expect(textResult.type).toBe("text"); + }); + + it("should support offset and limit for line-based reading", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + const lines = Array.from({ length: 10 }, (_, i) => `line${i + 1}`).join("\n"); + await write.executor.execute({ path: "multi.txt", content: lines }); + + const result = JSON.parse((await read.executor.execute({ path: "multi.txt", offset: 3, limit: 4 })) as string); + expect(result.content).toBe("line3\nline4\nline5\nline6"); + expect(result.startLine).toBe(3); + expect(result.endLine).toBe(6); + expect(result.totalLines).toBe(10); + }); + + it("should error when text file exceeds max lines without offset/limit", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + // 生成 201 行文本 + const lines = Array.from({ length: 201 }, (_, i) => `line${i + 1}`).join("\n"); + await write.executor.execute({ path: "big.txt", content: lines }); + + await expect(read.executor.execute({ path: "big.txt" })).rejects.toThrow(/201/); + await expect(read.executor.execute({ path: "big.txt" })).rejects.toThrow(/offset/); + }); + + it("should allow reading large file with offset/limit", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + const lines = Array.from({ length: 300 }, (_, i) => `line${i + 1}`).join("\n"); + await write.executor.execute({ path: "big.txt", content: lines }); + + const result = JSON.parse((await read.executor.execute({ path: "big.txt", offset: 290, limit: 11 })) as string); + expect(result.startLine).toBe(290); + expect(result.endLine).toBe(300); + expect(result.totalLines).toBe(300); + }); + + it("should clamp offset to valid range", async () => { + const write = getTool("opfs_write"); + const read = getTool("opfs_read"); + + await write.executor.execute({ path: "small.txt", content: "a\nb\nc" }); + + // offset 超出范围 + const result = JSON.parse((await read.executor.execute({ path: "small.txt", offset: 100, limit: 5 })) as string); + expect(result.content).toBe(""); + expect(result.startLine).toBe(100); + expect(result.endLine).toBe(3); + }); + }); + + describe("opfs_read errors", () => { + it("should throw for non-existent file", async () => { + const read = getTool("opfs_read"); + await expect(read.executor.execute({ path: "nope.txt" })).rejects.toThrow(); + }); + }); + + describe("opfs_list", () => { + it("should list files and directories", async () => { + const write = getTool("opfs_write"); + const list = getTool("opfs_list"); + + await write.executor.execute({ path: "file1.txt", content: "a" }); + await write.executor.execute({ path: "sub/file2.txt", content: "bb" }); + + const result = JSON.parse((await list.executor.execute({})) as string); + expect(result).toHaveLength(2); + + const fileEntry = result.find((e: any) => e.name === "file1.txt"); + expect(fileEntry).toEqual({ name: "file1.txt", type: "file", size: 1 }); + + const dirEntry = result.find((e: any) => e.name === "sub"); + expect(dirEntry).toEqual({ name: "sub", type: "directory" }); + }); + + it("should list subdirectory contents", async () => { + const write = getTool("opfs_write"); + const list = getTool("opfs_list"); + + await write.executor.execute({ path: "dir/a.txt", content: "aaa" }); + await write.executor.execute({ path: "dir/b.txt", content: "bb" }); + + const result = JSON.parse((await list.executor.execute({ path: "dir" })) as string); + expect(result).toHaveLength(2); + }); + + it("should return empty array for empty directory", async () => { + const list = getTool("opfs_list"); + const result = JSON.parse((await list.executor.execute({})) as string); + expect(result).toEqual([]); + }); + }); + + describe("opfs_delete", () => { + it("should delete a file", async () => { + const write = getTool("opfs_write"); + const del = getTool("opfs_delete"); + const read = getTool("opfs_read"); + + await write.executor.execute({ path: "temp.txt", content: "bye" }); + const result = JSON.parse((await del.executor.execute({ path: "temp.txt" })) as string); + expect(result).toEqual({ success: true }); + + await expect(read.executor.execute({ path: "temp.txt" })).rejects.toThrow(); + }); + + it("should throw for non-existent path", async () => { + const del = getTool("opfs_delete"); + await expect(del.executor.execute({ path: "ghost.txt" })).rejects.toThrow(); + }); + + it("should reject .. in path", async () => { + const del = getTool("opfs_delete"); + await expect(del.executor.execute({ path: "a/../../b" })).rejects.toThrow('".." is not allowed'); + }); + }); +}); diff --git a/src/app/service/agent/core/tools/opfs_tools.ts b/src/app/service/agent/core/tools/opfs_tools.ts new file mode 100644 index 000000000..a390d9801 --- /dev/null +++ b/src/app/service/agent/core/tools/opfs_tools.ts @@ -0,0 +1,245 @@ +import type { ToolDefinition } from "@App/app/service/agent/core/types"; +import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; +import { + sanitizePath, + getDirectory, + getWorkspaceRoot, + splitPath, + writeWorkspaceFile, +} from "@App/app/service/agent/core/opfs_helpers"; +import { isText } from "@App/pkg/utils/istextorbinary"; + +// re-export sanitizePath 供外部使用 +export { sanitizePath }; + +// ---- Tool Definitions ---- + +const OPFS_WRITE_DEFINITION: ToolDefinition = { + name: "opfs_write", + description: + "Write content to a file in the workspace. Supports text strings, Blob, and data URL (base64 auto-decoded to binary). Creates parent directories automatically. " + + "Text files can be read back via opfs_read. Binary data (images, downloads) are returned as blob URLs.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path relative to workspace root (e.g. 'notes/todo.txt')" }, + content: { type: "string", description: "Text content to write" }, + }, + required: ["path", "content"], + }, +}; + +/** 最大允许无分页直接返回的文本行数 */ +const MAX_TEXT_LINES = 200; + +const OPFS_READ_DEFINITION: ToolDefinition = { + name: "opfs_read", + description: + "Read a file from the workspace. " + + "By default auto-detects: text files return content, binary files return blob URL. " + + "Use 'mode' to override. If text exceeds 200 lines, use 'offset' and 'limit' to read in segments.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path relative to workspace root" }, + mode: { + type: "string", + enum: ["text", "blob", "auto"], + description: + "Return mode. 'text': force text content; 'blob': force blob URL (for passing images/binary to SkillScripts or page); 'auto' (default): detect by file content", + }, + offset: { + type: "number", + description: "Start line number (1-based). Only for text mode. Default: 1", + }, + limit: { + type: "number", + description: "Number of lines to read. Only for text mode. Default: all (up to 200)", + }, + }, + required: ["path"], + }, +}; + +const OPFS_LIST_DEFINITION: ToolDefinition = { + name: "opfs_list", + description: "List files and directories in a workspace directory.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "Directory path relative to workspace root (default: root)" }, + }, + }, +}; + +const OPFS_DELETE_DEFINITION: ToolDefinition = { + name: "opfs_delete", + description: "Delete a file or directory from the workspace.", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File or directory path relative to workspace root" }, + }, + required: ["path"], + }, +}; + +// ---- blob URL 创建(通过 Offscreen) ---- + +// 创建 blob URL 的回调,由外部注入(Offscreen 通道) +type CreateBlobUrlFn = (data: ArrayBuffer, mimeType: string) => Promise; +let createBlobUrlFn: CreateBlobUrlFn | null = null; + +/** 注入 Offscreen blob URL 创建函数 */ +export function setCreateBlobUrlFn(fn: CreateBlobUrlFn): void { + createBlobUrlFn = fn; +} + +/** 根据文件扩展名推断 MIME 类型(仅用于元数据,文本/二进制判断由 isText 负责) */ +export function guessMimeType(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() || ""; + const map: Record = { + txt: "text/plain", + md: "text/markdown", + html: "text/html", + htm: "text/html", + css: "text/css", + csv: "text/csv", + xml: "text/xml", + svg: "image/svg+xml", + js: "application/javascript", + mjs: "application/javascript", + json: "application/json", + yaml: "text/yaml", + yml: "text/yaml", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + mp3: "audio/mpeg", + wav: "audio/wav", + mp4: "video/mp4", + pdf: "application/pdf", + zip: "application/zip", + wasm: "application/wasm", + }; + return map[ext] || "application/octet-stream"; +} + +// ---- Factory ---- + +export function createOPFSTools(): { + tools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>; +} { + const writeExecutor: ToolExecutor = { + execute: async (args: Record) => { + const result = await writeWorkspaceFile(args.path as string, args.content as string | Blob); + return JSON.stringify(result); + }, + }; + + const readExecutor: ToolExecutor = { + execute: async (args: Record) => { + const safePath = sanitizePath(args.path as string); + if (!safePath) throw new Error("path is required"); + + const workspace = await getWorkspaceRoot(); + const { dirPath, fileName } = splitPath(safePath); + const dir = dirPath ? await getDirectory(workspace, dirPath) : workspace; + const fileHandle = await dir.getFileHandle(fileName); + const file = await fileHandle.getFile(); + const mimeType = guessMimeType(safePath); + const arrayBuffer = await file.arrayBuffer(); + + // 确定返回模式:auto 通过内容字节检测文本/二进制 + const mode = (args.mode as string) || "auto"; + const useText = mode === "text" || (mode === "auto" && isText(new Uint8Array(arrayBuffer))); + + // blob 模式:返回 blob URL + if (!useText) { + if (!createBlobUrlFn) { + throw new Error("Blob URL creation not available (Offscreen not initialized)"); + } + const blobUrl = await createBlobUrlFn(arrayBuffer, mimeType); + return JSON.stringify({ path: safePath, blobUrl, size: file.size, mimeType, type: "binary" }); + } + + // text 模式:返回文本内容 + const text = new TextDecoder().decode(arrayBuffer); + const lines = text.split("\n"); + const totalLines = lines.length; + + const offset = typeof args.offset === "number" ? args.offset : undefined; + const limit = typeof args.limit === "number" ? args.limit : undefined; + + // 超过行数限制且未指定分页参数,报错要求分段读取 + if (offset == null && limit == null && totalLines > MAX_TEXT_LINES) { + throw new Error( + `文件共 ${totalLines} 行,超过单次读取上限(${MAX_TEXT_LINES} 行)。` + + `请使用 offset 和 limit 参数分段读取,例如:offset=1, limit=${MAX_TEXT_LINES}` + ); + } + + const startLine = offset != null ? Math.max(1, offset) : 1; + const endLine = limit != null ? Math.min(totalLines, startLine + limit - 1) : totalLines; + const selectedLines = lines.slice(startLine - 1, endLine); + const content = selectedLines.join("\n"); + + return JSON.stringify({ + path: safePath, + content, + totalLines, + startLine, + endLine, + mimeType, + type: "text", + }); + }, + }; + + const listExecutor: ToolExecutor = { + execute: async (args: Record) => { + const rawPath = (args.path as string) || ""; + const safePath = sanitizePath(rawPath); + + const workspace = await getWorkspaceRoot(true); + const dir = safePath ? await getDirectory(workspace, safePath) : workspace; + + const entries: Array<{ name: string; type: "file" | "directory"; size?: number }> = []; + for await (const [name, handle] of dir as unknown as AsyncIterable<[string, FileSystemHandle]>) { + if (handle.kind === "file") { + const f = await (handle as FileSystemFileHandle).getFile(); + entries.push({ name, type: "file", size: f.size }); + } else { + entries.push({ name, type: "directory" }); + } + } + + return JSON.stringify(entries); + }, + }; + + const deleteExecutor: ToolExecutor = { + execute: async (args: Record) => { + const safePath = sanitizePath(args.path as string); + if (!safePath) throw new Error("path is required"); + + const workspace = await getWorkspaceRoot(); + const { dirPath, fileName } = splitPath(safePath); + const dir = dirPath ? await getDirectory(workspace, dirPath) : workspace; + await dir.removeEntry(fileName, { recursive: true }); + + return JSON.stringify({ success: true }); + }, + }; + + return { + tools: [ + { definition: OPFS_WRITE_DEFINITION, executor: writeExecutor }, + { definition: OPFS_READ_DEFINITION, executor: readExecutor }, + { definition: OPFS_LIST_DEFINITION, executor: listExecutor }, + { definition: OPFS_DELETE_DEFINITION, executor: deleteExecutor }, + ], + }; +} diff --git a/src/app/service/agent/core/tools/search_config.ts b/src/app/service/agent/core/tools/search_config.ts new file mode 100644 index 000000000..c35b03f73 --- /dev/null +++ b/src/app/service/agent/core/tools/search_config.ts @@ -0,0 +1,26 @@ +export type SearchEngineConfig = { + engine: "bing" | "duckduckgo" | "baidu" | "google_custom"; + googleApiKey?: string; + googleCseId?: string; +}; + +const STORAGE_KEY = "agent_search_config"; + +const DEFAULT_CONFIG: SearchEngineConfig = { + engine: "bing", +}; + +export class SearchConfigRepo { + async getConfig(): Promise { + try { + const result = await chrome.storage.local.get(STORAGE_KEY); + return result[STORAGE_KEY] || DEFAULT_CONFIG; + } catch { + return DEFAULT_CONFIG; + } + } + + async saveConfig(config: SearchEngineConfig): Promise { + await chrome.storage.local.set({ [STORAGE_KEY]: config }); + } +} diff --git a/src/app/service/agent/core/tools/sub_agent.test.ts b/src/app/service/agent/core/tools/sub_agent.test.ts new file mode 100644 index 000000000..3217cac49 --- /dev/null +++ b/src/app/service/agent/core/tools/sub_agent.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from "vitest"; +import { createSubAgentTool } from "./sub_agent"; + +describe("sub_agent", () => { + it("should call runSubAgent with correct parameters", async () => { + const mockRunSubAgent = vi.fn().mockResolvedValue({ agentId: "test-id", result: "Sub-agent result" }); + + const { definition, executor } = createSubAgentTool({ runSubAgent: mockRunSubAgent }); + + expect(definition.name).toBe("agent"); + + const result = await executor.execute({ prompt: "Search for X", description: "Searching X" }); + + expect(mockRunSubAgent).toHaveBeenCalledWith({ + prompt: "Search for X", + description: "Searching X", + type: undefined, + to: undefined, + }); + expect(result).toContain("[agentId: test-id]"); + expect(result).toContain("Sub-agent result"); + }); + + it("should use default description if not provided", async () => { + const mockRunSubAgent = vi.fn().mockResolvedValue({ agentId: "id2", result: "done" }); + const { executor } = createSubAgentTool({ runSubAgent: mockRunSubAgent }); + + await executor.execute({ prompt: "Do something" }); + expect(mockRunSubAgent).toHaveBeenCalledWith({ + prompt: "Do something", + description: "Sub-agent task", + type: undefined, + to: undefined, + }); + }); + + it("should pass type and to parameters", async () => { + const mockRunSubAgent = vi.fn().mockResolvedValue({ agentId: "id3", result: "ok" }); + const { executor } = createSubAgentTool({ runSubAgent: mockRunSubAgent }); + + await executor.execute({ prompt: "Research X", type: "researcher", to: "prev-agent-id" }); + expect(mockRunSubAgent).toHaveBeenCalledWith({ + prompt: "Research X", + description: "Sub-agent task", + type: "researcher", + to: "prev-agent-id", + }); + }); + + it("should throw if prompt is missing", async () => { + const mockRunSubAgent = vi.fn(); + const { executor } = createSubAgentTool({ runSubAgent: mockRunSubAgent }); + + await expect(executor.execute({})).rejects.toThrow("prompt is required"); + expect(mockRunSubAgent).not.toHaveBeenCalled(); + }); + + it("should propagate errors from runSubAgent", async () => { + const mockRunSubAgent = vi.fn().mockRejectedValue(new Error("Agent failed")); + const { executor } = createSubAgentTool({ runSubAgent: mockRunSubAgent }); + + await expect(executor.execute({ prompt: "fail" })).rejects.toThrow("Agent failed"); + }); +}); diff --git a/src/app/service/agent/core/tools/sub_agent.ts b/src/app/service/agent/core/tools/sub_agent.ts new file mode 100644 index 000000000..2d355b38e --- /dev/null +++ b/src/app/service/agent/core/tools/sub_agent.ts @@ -0,0 +1,79 @@ +import type { SubAgentDetails, ToolDefinition } from "@App/app/service/agent/core/types"; +import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; + +// 子代理运行选项 +export type SubAgentRunOptions = { + prompt: string; + description: string; + type?: string; + to?: string; // 延续已有子代理 +}; + +// 子代理运行结果 +export type SubAgentRunResult = { + agentId: string; + result: string; + details?: SubAgentDetails; // 执行详情(用于持久化) +}; + +export const SUB_AGENT_DEFINITION: ToolDefinition = { + name: "agent", + description: + "Launch a sub-agent to handle a subtask autonomously. Sub-agents run in their own conversation context. Use the `type` parameter to select a specialized sub-agent, or use `to` to continue a previous sub-agent with follow-up instructions.", + parameters: { + type: "object", + properties: { + prompt: { + type: "string", + description: "The task description or follow-up message for the sub-agent. Be specific about what you need.", + }, + description: { + type: "string", + description: + "A short (3-5 word) description of what the sub-agent will do, shown in the UI. Optional when resuming a previous sub-agent via `to`.", + }, + type: { + type: "string", + description: + "Sub-agent type. Available types: 'researcher' (web search/fetch, data analysis, no tab interaction), 'page_operator' (browser tab interaction, page automation), 'general' (all tools, default). Choose the most specific type for better results.", + }, + to: { + type: "string", + description: + "agentId of a previously completed sub-agent. Sends a follow-up message while preserving the sub-agent's full conversation context.", + }, + }, + required: ["prompt"], + }, +}; + +export function createSubAgentTool(params: { + runSubAgent: (options: SubAgentRunOptions) => Promise; +}): { + definition: ToolDefinition; + executor: ToolExecutor; +} { + const executor: ToolExecutor = { + execute: async (args: Record) => { + const prompt = args.prompt as string; + const description = (args.description as string) || "Sub-agent task"; + const type = args.type as string | undefined; + const to = args.to as string | undefined; + + if (!prompt) { + throw new Error("prompt is required"); + } + + const result = await params.runSubAgent({ prompt, description, type, to }); + + // 返回结构化结果,附带子代理执行详情用于持久化 + const content = `[agentId: ${result.agentId}]\n\n${result.result}`; + if (result.details) { + return { content, subAgentDetails: result.details }; + } + return content; + }, + }; + + return { definition: SUB_AGENT_DEFINITION, executor }; +} diff --git a/src/app/service/agent/core/tools/tab_tools.test.ts b/src/app/service/agent/core/tools/tab_tools.test.ts new file mode 100644 index 000000000..49188df1f --- /dev/null +++ b/src/app/service/agent/core/tools/tab_tools.test.ts @@ -0,0 +1,433 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createTabTools } from "./tab_tools"; + +// mock chrome.scripting +const mockExecuteScript = vi.fn(); +// mock chrome.tabs +const mockTabsQuery = vi.fn(); +const mockTabsCreate = vi.fn(); +const mockTabsRemove = vi.fn(); +const mockTabsUpdate = vi.fn(); +// mock chrome.windows +const mockWindowsUpdate = vi.fn(); + +// mock offscreen extractHtmlWithSelectors 的返回值 +let mockExtractReturn: string | null = "Extracted content with selectors for testing"; +let mockExtractShouldThrow = false; + +const mockSender = { + sendMessage: vi.fn().mockImplementation(() => { + if (mockExtractShouldThrow) { + return Promise.reject(new Error("Offscreen unavailable")); + } + return Promise.resolve({ data: mockExtractReturn }); + }), +} as any; + +const mockSummarize = vi.fn().mockResolvedValue("Summarized content"); + +beforeEach(() => { + vi.clearAllMocks(); + mockExtractReturn = "Extracted content with selectors for testing"; + mockExtractShouldThrow = false; + + (chrome as any).scripting = { executeScript: mockExecuteScript }; + (chrome as any).tabs = { + query: mockTabsQuery, + create: mockTabsCreate, + remove: mockTabsRemove, + update: mockTabsUpdate, + }; + (chrome as any).windows = { update: mockWindowsUpdate }; +}); + +function makeTools() { + return createTabTools({ sender: mockSender, summarize: mockSummarize }); +} + +function getExecutor(name: string) { + const { tools } = makeTools(); + const tool = tools.find((t) => t.definition.name === name); + if (!tool) throw new Error(`Tool ${name} not found`); + return tool.executor; +} + +describe("createTabTools", () => { + it("should create 5 tools", () => { + const { tools } = makeTools(); + expect(tools).toHaveLength(5); + const names = tools.map((t) => t.definition.name); + expect(names).toContain("get_tab_content"); + expect(names).toContain("list_tabs"); + expect(names).toContain("open_tab"); + expect(names).toContain("close_tab"); + expect(names).toContain("activate_tab"); + }); +}); + +describe("get_tab_content", () => { + it("should throw when tab_id is missing", async () => { + const executor = getExecutor("get_tab_content"); + await expect(executor.execute({})).rejects.toThrow("tab_id is required"); + }); + + it("should inject script and return extracted content", async () => { + mockExecuteScript.mockResolvedValue([ + { result: { html: "
Hello World
", title: "Test", url: "https://example.com" } }, + ]); + + const executor = getExecutor("get_tab_content"); + const raw = (await executor.execute({ tab_id: 42 })) as string; + const result = JSON.parse(raw); + + expect(result.tab_id).toBe(42); + expect(result.url).toBe("https://example.com"); + expect(result.title).toBe("Test"); + expect(result.content).toBe("Extracted content with selectors for testing"); + expect(result.truncated).toBe(false); + expect(mockExecuteScript).toHaveBeenCalledWith(expect.objectContaining({ target: { tabId: 42 }, world: "MAIN" })); + }); + + it("should handle selector parameter", async () => { + mockExecuteScript.mockResolvedValue([ + { result: { html: "
Section
", title: "Test", url: "https://example.com" } }, + ]); + + const executor = getExecutor("get_tab_content"); + const raw = (await executor.execute({ tab_id: 1, selector: "#main" })) as string; + const result = JSON.parse(raw); + + expect(result.used_selector).toBe("#main"); + // Verify selector was passed to injected script + const callArgs = mockExecuteScript.mock.calls[0][0].args[0]; + expect(callArgs.selector).toBe("#main"); + }); + + it("should handle element not found", async () => { + mockExecuteScript.mockResolvedValue([ + { result: { html: null, title: "Test", url: "https://example.com", error: "Element not found: #missing" } }, + ]); + + const executor = getExecutor("get_tab_content"); + const raw = (await executor.execute({ tab_id: 1 })) as string; + const result = JSON.parse(raw); + + expect(result.content).toBe("Element not found: #missing"); + }); + + it("should truncate at max_length", async () => { + mockExecuteScript.mockResolvedValue([ + { result: { html: "
content
", title: "Test", url: "https://example.com" } }, + ]); + mockExtractReturn = "A".repeat(200); + + const executor = getExecutor("get_tab_content"); + const raw = (await executor.execute({ tab_id: 1, max_length: 50 })) as string; + const result = JSON.parse(raw); + + expect(result.content.length).toBe(50); + expect(result.truncated).toBe(true); + }); + + it("should call summarize when prompt is provided", async () => { + mockExecuteScript.mockResolvedValue([ + { result: { html: "
Hello
", title: "Test", url: "https://example.com" } }, + ]); + + const executor = getExecutor("get_tab_content"); + const raw = (await executor.execute({ tab_id: 1, prompt: "What is the price?" })) as string; + const result = JSON.parse(raw); + + expect(mockSummarize).toHaveBeenCalledWith(expect.any(String), "What is the price?"); + expect(result.content).toBe("Summarized content"); + expect(result.truncated).toBe(false); + }); + + it("should fallback when offscreen extraction throws", async () => { + mockExecuteScript.mockResolvedValue([ + { result: { html: "
Fallback text
", title: "Test", url: "https://example.com" } }, + ]); + mockExtractShouldThrow = true; + + const executor = getExecutor("get_tab_content"); + const raw = (await executor.execute({ tab_id: 1 })) as string; + const result = JSON.parse(raw); + + // 降级到简单去标签 + expect(result.content).toContain("Fallback text"); + }); + + it("should throw when executeScript fails", async () => { + mockExecuteScript.mockResolvedValue([]); + + const executor = getExecutor("get_tab_content"); + await expect(executor.execute({ tab_id: 1 })).rejects.toThrow("Failed to read tab content"); + }); + + it("should fallback to raw HTML when extraction returns short content", async () => { + mockExecuteScript.mockResolvedValue([ + { result: { html: "
Hi
", title: "Test", url: "https://example.com" } }, + ]); + mockExtractReturn = "Hi"; // shorter than 20 chars + + const executor = getExecutor("get_tab_content"); + const raw = (await executor.execute({ tab_id: 1 })) as string; + const result = JSON.parse(raw); + + // 降级到原始 HTML + expect(result.content).toBe("
Hi
"); + }); + + it("should return 'No content' when html is null without error", async () => { + mockExecuteScript.mockResolvedValue([{ result: { html: null, title: "Test", url: "https://example.com" } }]); + + const executor = getExecutor("get_tab_content"); + const raw = (await executor.execute({ tab_id: 1 })) as string; + const result = JSON.parse(raw); + + expect(result.content).toBe("No content"); + }); + + it("should propagate summarize errors", async () => { + mockExecuteScript.mockResolvedValue([ + { result: { html: "
content
", title: "Test", url: "https://example.com" } }, + ]); + mockSummarize.mockRejectedValue(new Error("No model configured")); + + const executor = getExecutor("get_tab_content"); + await expect(executor.execute({ tab_id: 1, prompt: "summarize" })).rejects.toThrow("No model configured"); + }); + + it("should set used_selector to null when no selector provided", async () => { + mockExecuteScript.mockResolvedValue([ + { result: { html: "
content
", title: "Test", url: "https://example.com" } }, + ]); + + const executor = getExecutor("get_tab_content"); + const raw = (await executor.execute({ tab_id: 1 })) as string; + const result = JSON.parse(raw); + + expect(result.used_selector).toBeNull(); + }); +}); + +describe("list_tabs", () => { + it("should return all tabs", async () => { + mockTabsQuery.mockResolvedValue([ + { + id: 1, + url: "https://a.com", + title: "A", + active: true, + windowId: 1, + index: 0, + audible: false, + status: "complete", + }, + { + id: 2, + url: "https://b.com", + title: "B", + active: false, + windowId: 1, + index: 1, + audible: true, + status: "loading", + }, + ]); + + const executor = getExecutor("list_tabs"); + const raw = (await executor.execute({})) as string; + const result = JSON.parse(raw); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe(1); + expect(result[1].audible).toBe(true); + }); + + it("should filter by url_pattern", async () => { + mockTabsQuery.mockResolvedValue([ + { id: 1, url: "https://github.com/repo", title: "GitHub", active: true, windowId: 1, index: 0 }, + { id: 2, url: "https://google.com", title: "Google", active: false, windowId: 1, index: 1 }, + ]); + + const executor = getExecutor("list_tabs"); + const raw = (await executor.execute({ url_pattern: "github" })) as string; + const result = JSON.parse(raw); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + }); + + it("should filter by title_pattern", async () => { + mockTabsQuery.mockResolvedValue([ + { id: 1, url: "https://a.com", title: "Shopping Cart", active: true, windowId: 1, index: 0 }, + { id: 2, url: "https://b.com", title: "Blog Post", active: false, windowId: 1, index: 1 }, + ]); + + const executor = getExecutor("list_tabs"); + const raw = (await executor.execute({ title_pattern: "shopping" })) as string; + const result = JSON.parse(raw); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Shopping Cart"); + }); + + it("should pass active/audible/windowId to query", async () => { + mockTabsQuery.mockResolvedValue([]); + + const executor = getExecutor("list_tabs"); + await executor.execute({ active: true, window_id: 5, audible: false }); + + expect(mockTabsQuery).toHaveBeenCalledWith({ active: true, windowId: 5, audible: false }); + }); + + it("should exclude tabs without id", async () => { + mockTabsQuery.mockResolvedValue([ + { id: 1, url: "https://a.com", title: "A", active: true, windowId: 1, index: 0 }, + { url: "https://b.com", title: "B", active: false, windowId: 1, index: 1 }, // no id + ]); + + const executor = getExecutor("list_tabs"); + const raw = (await executor.execute({})) as string; + const result = JSON.parse(raw); + + expect(result).toHaveLength(1); + }); +}); + +describe("open_tab", () => { + it("should throw when url is missing", async () => { + const executor = getExecutor("open_tab"); + await expect(executor.execute({})).rejects.toThrow("url is required"); + }); + + it("should create a new tab", async () => { + mockTabsCreate.mockResolvedValue({ + id: 10, + url: "https://example.com", + title: "Example", + windowId: 1, + index: 3, + }); + + const executor = getExecutor("open_tab"); + const raw = (await executor.execute({ url: "https://example.com" })) as string; + const result = JSON.parse(raw); + + expect(result.id).toBe(10); + expect(result.url).toBe("https://example.com"); + expect(mockTabsCreate).toHaveBeenCalledWith({ url: "https://example.com", active: true }); + }); + + it("should create background tab when active=false", async () => { + mockTabsCreate.mockResolvedValue({ id: 11, url: "https://bg.com", title: "", windowId: 1, index: 4 }); + + const executor = getExecutor("open_tab"); + await executor.execute({ url: "https://bg.com", active: false }); + + expect(mockTabsCreate).toHaveBeenCalledWith({ url: "https://bg.com", active: false }); + }); + + it("should pass window_id if provided", async () => { + mockTabsCreate.mockResolvedValue({ id: 12, url: "https://a.com", title: "", windowId: 2, index: 0 }); + + const executor = getExecutor("open_tab"); + await executor.execute({ url: "https://a.com", window_id: 2 }); + + expect(mockTabsCreate).toHaveBeenCalledWith({ url: "https://a.com", active: true, windowId: 2 }); + }); + + it("should use pendingUrl when url is undefined", async () => { + mockTabsCreate.mockResolvedValue({ + id: 13, + url: undefined, + pendingUrl: "https://pending.com", + title: "", + windowId: 1, + index: 0, + }); + + const executor = getExecutor("open_tab"); + const raw = (await executor.execute({ url: "https://pending.com" })) as string; + const result = JSON.parse(raw); + + expect(result.url).toBe("https://pending.com"); + }); +}); + +describe("close_tab", () => { + it("should throw when tab_id is missing", async () => { + const executor = getExecutor("close_tab"); + await expect(executor.execute({})).rejects.toThrow("tab_id is required"); + }); + + it("should close the tab", async () => { + mockTabsRemove.mockResolvedValue(undefined); + + const executor = getExecutor("close_tab"); + const raw = (await executor.execute({ tab_id: 5 })) as string; + const result = JSON.parse(raw); + + expect(result.success).toBe(true); + expect(result.tab_id).toBe(5); + expect(mockTabsRemove).toHaveBeenCalledWith(5); + }); + + it("should propagate error when tab does not exist", async () => { + mockTabsRemove.mockRejectedValue(new Error("No tab with id: 999")); + + const executor = getExecutor("close_tab"); + await expect(executor.execute({ tab_id: 999 })).rejects.toThrow("No tab with id: 999"); + }); +}); + +describe("activate_tab", () => { + it("should throw when tab_id is missing", async () => { + const executor = getExecutor("activate_tab"); + await expect(executor.execute({})).rejects.toThrow("tab_id is required"); + }); + + it("should activate the tab and focus the window", async () => { + mockTabsUpdate.mockResolvedValue({ + id: 7, + url: "https://example.com", + title: "Example", + active: true, + windowId: 3, + }); + mockWindowsUpdate.mockResolvedValue({}); + + const executor = getExecutor("activate_tab"); + const raw = (await executor.execute({ tab_id: 7 })) as string; + const result = JSON.parse(raw); + + expect(result.id).toBe(7); + expect(result.active).toBe(true); + expect(mockTabsUpdate).toHaveBeenCalledWith(7, { active: true }); + expect(mockWindowsUpdate).toHaveBeenCalledWith(3, { focused: true }); + }); + + it("should throw when tab is not found", async () => { + mockTabsUpdate.mockResolvedValue(undefined); + + const executor = getExecutor("activate_tab"); + await expect(executor.execute({ tab_id: 999 })).rejects.toThrow("Tab 999 not found"); + }); + + it("should not call windows.update when windowId is falsy", async () => { + mockTabsUpdate.mockResolvedValue({ + id: 8, + url: "https://example.com", + title: "Example", + active: true, + windowId: 0, + }); + + const executor = getExecutor("activate_tab"); + await executor.execute({ tab_id: 8 }); + + expect(mockTabsUpdate).toHaveBeenCalledWith(8, { active: true }); + expect(mockWindowsUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/agent/core/tools/tab_tools.ts b/src/app/service/agent/core/tools/tab_tools.ts new file mode 100644 index 000000000..7e0908238 --- /dev/null +++ b/src/app/service/agent/core/tools/tab_tools.ts @@ -0,0 +1,295 @@ +import type { ToolDefinition } from "@App/app/service/agent/core/types"; +import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; +import type { MessageSend } from "@Packages/message/types"; +import { extractHtmlWithSelectors } from "@App/app/service/offscreen/client"; + +// ---- Tool Definitions ---- + +const GET_TAB_CONTENT_DEFINITION: ToolDefinition = { + name: "get_tab_content", + description: + "Read page content and extract information via LLM. Returns markdown with CSS selector annotations (as `` comments) for key elements. " + + "Use this BEFORE execute_script to understand page structure and discover correct selectors. " + + "Provide a prompt describing what to extract — e.g., 'find the title input, content editor, and submit button — return their CSS selectors and current state'. " + + "Use selector parameter to narrow scope to a specific section.", + parameters: { + type: "object", + properties: { + tab_id: { type: "number", description: "Target tab ID (use list_tabs to find)" }, + prompt: { + type: "string", + description: + "Describe what information to extract/summarize from the page content. Required for efficient context usage.", + }, + selector: { + type: "string", + description: "CSS selector to extract only matching element's content (e.g. '#main', '.article-body')", + }, + max_length: { type: "number", description: "Max characters to return (default: no limit)" }, + }, + required: ["tab_id", "prompt"], + }, +}; + +const LIST_TABS_DEFINITION: ToolDefinition = { + name: "list_tabs", + description: "List open browser tabs with their IDs, URLs, titles, and status.", + parameters: { + type: "object", + properties: { + url_pattern: { type: "string", description: "Regex pattern to filter tabs by URL" }, + title_pattern: { type: "string", description: "Regex pattern to filter tabs by title" }, + active: { type: "boolean", description: "Filter by active/inactive state" }, + window_id: { type: "number", description: "Filter by window ID" }, + audible: { type: "boolean", description: "Filter tabs playing audio" }, + }, + }, +}; + +const OPEN_TAB_DEFINITION: ToolDefinition = { + name: "open_tab", + description: "Open a new browser tab with the given URL.", + parameters: { + type: "object", + properties: { + url: { type: "string", description: "URL to open" }, + active: { type: "boolean", description: "Whether to activate the tab (default: true, false for background)" }, + window_id: { type: "number", description: "Window to open the tab in" }, + }, + required: ["url"], + }, +}; + +const CLOSE_TAB_DEFINITION: ToolDefinition = { + name: "close_tab", + description: "Close a browser tab.", + parameters: { + type: "object", + properties: { + tab_id: { type: "number", description: "Tab ID to close" }, + }, + required: ["tab_id"], + }, +}; + +const ACTIVATE_TAB_DEFINITION: ToolDefinition = { + name: "activate_tab", + description: "Activate (switch to) a browser tab.", + parameters: { + type: "object", + properties: { + tab_id: { type: "number", description: "Tab ID to activate" }, + }, + required: ["tab_id"], + }, +}; + +// ---- Factory ---- + +export function createTabTools(deps: { + sender: MessageSend; + summarize: (content: string, prompt: string) => Promise; +}): { tools: Array<{ definition: ToolDefinition; executor: ToolExecutor }> } { + const { sender, summarize } = deps; + + const getTabContentExecutor: ToolExecutor = { + execute: async (args: Record) => { + const tabId = args.tab_id as number; + const prompt = args.prompt as string | undefined; + const selector = args.selector as string | undefined; + const maxLength = args.max_length as number | undefined; + + if (tabId == null) throw new Error("tab_id is required"); + + // 注入脚本获取页面 HTML + const removeTags = ["script", "style", "noscript", "svg", "link[rel=stylesheet]", "iframe"]; + + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (opts: { selector?: string; removeTags: string[] }) => { + const root = opts.selector ? document.querySelector(opts.selector) : document.documentElement; + if (!root) { + return { + html: null, + title: document.title, + url: location.href, + error: `Element not found: ${opts.selector}`, + }; + } + const clone = root.cloneNode(true) as Element; + for (const tag of opts.removeTags) { + clone.querySelectorAll(tag).forEach((el) => el.remove()); + } + return { html: clone.outerHTML, title: document.title, url: location.href }; + }, + args: [{ selector, removeTags }], + world: "MAIN" as chrome.scripting.ExecutionWorld, + }); + + if (!results || results.length === 0) { + throw new Error("Failed to read tab content"); + } + + const pageData = results[0].result as { html: string | null; title: string; url: string; error?: string }; + + if (pageData.error || !pageData.html) { + return JSON.stringify({ + tab_id: tabId, + url: pageData.url, + title: pageData.title, + content: pageData.error || "No content", + truncated: false, + used_selector: selector || null, + }); + } + + // 通过 Offscreen 提取 markdown(带 selector 标注) + let content: string; + try { + const extracted = await extractHtmlWithSelectors(sender, pageData.html); + content = extracted && extracted.length > 20 ? extracted : pageData.html; + } catch { + // 降级:简单去标签 + content = pageData.html + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + // 截断 + let truncated = false; + if (maxLength != null && content.length > maxLength) { + content = content.slice(0, maxLength); + truncated = true; + } + + // LLM 摘要 + if (prompt) { + content = await summarize(content, prompt); + truncated = false; // 摘要后不再截断 + } + + return JSON.stringify({ + tab_id: tabId, + url: pageData.url, + title: pageData.title, + content, + truncated, + used_selector: selector || null, + }); + }, + }; + + const listTabsExecutor: ToolExecutor = { + execute: async (args: Record) => { + const urlPattern = args.url_pattern as string | undefined; + const titlePattern = args.title_pattern as string | undefined; + const active = args.active as boolean | undefined; + const windowId = args.window_id as number | undefined; + const audible = args.audible as boolean | undefined; + + const queryInfo: chrome.tabs.QueryInfo = {}; + if (active != null) queryInfo.active = active; + if (windowId != null) queryInfo.windowId = windowId; + if (audible != null) queryInfo.audible = audible; + + let tabs = await chrome.tabs.query(queryInfo); + + // 正则过滤 + if (urlPattern) { + let re: RegExp; + try { + re = new RegExp(urlPattern, "i"); + } catch { + throw new Error(`Invalid url_pattern regex: "${urlPattern}"`); + } + tabs = tabs.filter((t) => re.test(t.url || "")); + } + if (titlePattern) { + let re: RegExp; + try { + re = new RegExp(titlePattern, "i"); + } catch { + throw new Error(`Invalid title_pattern regex: "${titlePattern}"`); + } + tabs = tabs.filter((t) => re.test(t.title || "")); + } + + return JSON.stringify( + tabs + .filter((t) => t.id != null) + .map((t) => ({ + id: t.id, + url: t.url || "", + title: t.title || "", + active: t.active || false, + windowId: t.windowId, + index: t.index, + audible: t.audible || false, + status: t.status || "unknown", + })) + ); + }, + }; + + const openTabExecutor: ToolExecutor = { + execute: async (args: Record) => { + const url = args.url as string; + const active = (args.active as boolean | undefined) ?? true; + const windowId = args.window_id as number | undefined; + + if (!url) throw new Error("url is required"); + + const createProps: chrome.tabs.CreateProperties = { url, active }; + if (windowId != null) createProps.windowId = windowId; + + const tab = await chrome.tabs.create(createProps); + return JSON.stringify({ + id: tab.id, + url: tab.url || tab.pendingUrl || url, + title: tab.title || "", + windowId: tab.windowId, + index: tab.index, + }); + }, + }; + + const closeTabExecutor: ToolExecutor = { + execute: async (args: Record) => { + const tabId = args.tab_id as number; + if (tabId == null) throw new Error("tab_id is required"); + await chrome.tabs.remove(tabId); + return JSON.stringify({ success: true, tab_id: tabId }); + }, + }; + + const activateTabExecutor: ToolExecutor = { + execute: async (args: Record) => { + const tabId = args.tab_id as number; + if (tabId == null) throw new Error("tab_id is required"); + const tab = await chrome.tabs.update(tabId, { active: true }); + if (!tab) throw new Error(`Tab ${tabId} not found`); + // 也激活对应窗口 + if (tab.windowId) { + await chrome.windows.update(tab.windowId, { focused: true }); + } + return JSON.stringify({ + id: tab.id, + url: tab.url || "", + title: tab.title || "", + active: true, + windowId: tab.windowId, + }); + }, + }; + + return { + tools: [ + { definition: GET_TAB_CONTENT_DEFINITION, executor: getTabContentExecutor }, + { definition: LIST_TABS_DEFINITION, executor: listTabsExecutor }, + { definition: OPEN_TAB_DEFINITION, executor: openTabExecutor }, + { definition: CLOSE_TAB_DEFINITION, executor: closeTabExecutor }, + { definition: ACTIVATE_TAB_DEFINITION, executor: activateTabExecutor }, + ], + }; +} diff --git a/src/app/service/agent/core/tools/task_tools.test.ts b/src/app/service/agent/core/tools/task_tools.test.ts new file mode 100644 index 000000000..67901777e --- /dev/null +++ b/src/app/service/agent/core/tools/task_tools.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi } from "vitest"; +import { createTaskTools, type Task } from "./task_tools"; + +describe("task_tools", () => { + it("应创建 5 个工具", () => { + const { tools } = createTaskTools(); + expect(tools).toHaveLength(5); + const names = tools.map((t) => t.definition.name); + expect(names).toEqual(["create_task", "update_task", "get_task", "delete_task", "list_tasks"]); + }); + + it("create_task 应创建自增 ID 的任务", async () => { + const { tools } = createTaskTools(); + const create = tools.find((t) => t.definition.name === "create_task")!; + + const result1 = JSON.parse((await create.executor.execute({ subject: "Task 1" })) as string); + expect(result1).toEqual({ id: "1", subject: "Task 1", status: "pending" }); + + const result2 = JSON.parse( + (await create.executor.execute({ subject: "Task 2", description: "Details" })) as string + ); + expect(result2).toEqual({ id: "2", subject: "Task 2", description: "Details", status: "pending" }); + }); + + it("update_task 应更新任务字段", async () => { + const { tools } = createTaskTools(); + const create = tools.find((t) => t.definition.name === "create_task")!; + const update = tools.find((t) => t.definition.name === "update_task")!; + + await create.executor.execute({ subject: "Original" }); + + const result = JSON.parse( + (await update.executor.execute({ task_id: "1", status: "in_progress", subject: "Updated" })) as string + ); + expect(result.status).toBe("in_progress"); + expect(result.subject).toBe("Updated"); + }); + + it("update_task 应对不存在的任务抛错", async () => { + const { tools } = createTaskTools(); + const update = tools.find((t) => t.definition.name === "update_task")!; + await expect(update.executor.execute({ task_id: "1" })).rejects.toThrow(); + }); + + it("list_tasks 应返回所有任务摘要", async () => { + const { tools } = createTaskTools(); + const create = tools.find((t) => t.definition.name === "create_task")!; + const list = tools.find((t) => t.definition.name === "list_tasks")!; + + await create.executor.execute({ subject: "A" }); + await create.executor.execute({ subject: "B" }); + + const result = JSON.parse((await list.executor.execute({})) as string); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ id: "1", subject: "A", status: "pending" }); + expect(result[1]).toEqual({ id: "2", subject: "B", status: "pending" }); + }); + + it("list_tasks 初始应返回空数组", async () => { + const { tools } = createTaskTools(); + const list = tools.find((t) => t.definition.name === "list_tasks")!; + const result = JSON.parse((await list.executor.execute({})) as string); + expect(result).toEqual([]); + }); + + it("应从 initialTasks 恢复任务并继续递增 ID", async () => { + const initial: Task[] = [ + { id: "3", subject: "Existing", status: "in_progress" }, + { id: "5", subject: "Another", status: "pending" }, + ]; + const { tools } = createTaskTools({ initialTasks: initial }); + const create = tools.find((t) => t.definition.name === "create_task")!; + const list = tools.find((t) => t.definition.name === "list_tasks")!; + + // 新任务 ID 应从 6 开始(max existing ID 5 + 1) + const result = JSON.parse((await create.executor.execute({ subject: "New" })) as string); + expect(result.id).toBe("6"); + + const all = JSON.parse((await list.executor.execute({})) as string); + expect(all).toHaveLength(3); + }); + + it("create_task 应调用 onSave 和 sendEvent", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const sendEvent = vi.fn(); + const { tools } = createTaskTools({ onSave, sendEvent }); + const create = tools.find((t) => t.definition.name === "create_task")!; + + await create.executor.execute({ subject: "Test" }); + + expect(onSave).toHaveBeenCalledOnce(); + expect(onSave).toHaveBeenCalledWith([{ id: "1", subject: "Test", status: "pending" }]); + + expect(sendEvent).toHaveBeenCalledOnce(); + expect(sendEvent).toHaveBeenCalledWith({ + type: "task_update", + tasks: [{ id: "1", subject: "Test", status: "pending" }], + }); + }); + + it("update_task 应调用 onSave 和 sendEvent", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const sendEvent = vi.fn(); + const { tools } = createTaskTools({ onSave, sendEvent }); + const create = tools.find((t) => t.definition.name === "create_task")!; + const update = tools.find((t) => t.definition.name === "update_task")!; + + await create.executor.execute({ subject: "Task" }); + onSave.mockClear(); + sendEvent.mockClear(); + + await update.executor.execute({ task_id: "1", status: "completed" }); + + expect(onSave).toHaveBeenCalledOnce(); + expect(sendEvent).toHaveBeenCalledOnce(); + expect(sendEvent).toHaveBeenCalledWith({ + type: "task_update", + tasks: [{ id: "1", subject: "Task", status: "completed" }], + }); + }); + + it("list_tasks 不应触发 onSave 或 sendEvent", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const sendEvent = vi.fn(); + const initial: Task[] = [{ id: "1", subject: "Existing", status: "pending" }]; + const { tools } = createTaskTools({ initialTasks: initial, onSave, sendEvent }); + const list = tools.find((t) => t.definition.name === "list_tasks")!; + + await list.executor.execute({}); + + expect(onSave).not.toHaveBeenCalled(); + expect(sendEvent).not.toHaveBeenCalled(); + }); + + it("get_task 应返回任务完整信息", async () => { + const { tools } = createTaskTools(); + const create = tools.find((t) => t.definition.name === "create_task")!; + const get = tools.find((t) => t.definition.name === "get_task")!; + + await create.executor.execute({ subject: "Task", description: "Detailed info" }); + + const result = JSON.parse((await get.executor.execute({ task_id: "1" })) as string); + expect(result).toEqual({ id: "1", subject: "Task", description: "Detailed info", status: "pending" }); + }); + + it("get_task 应对不存在的任务抛错", async () => { + const { tools } = createTaskTools(); + const get = tools.find((t) => t.definition.name === "get_task")!; + await expect(get.executor.execute({ task_id: "999" })).rejects.toThrow('Task "999" not found'); + }); + + it("delete_task 应删除任务", async () => { + const { tools } = createTaskTools(); + const create = tools.find((t) => t.definition.name === "create_task")!; + const del = tools.find((t) => t.definition.name === "delete_task")!; + const list = tools.find((t) => t.definition.name === "list_tasks")!; + + await create.executor.execute({ subject: "A" }); + await create.executor.execute({ subject: "B" }); + + const result = JSON.parse((await del.executor.execute({ task_id: "1" })) as string); + expect(result).toEqual({ deleted: true, task_id: "1" }); + + const remaining = JSON.parse((await list.executor.execute({})) as string); + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe("2"); + }); + + it("delete_task 应对不存在的任务抛错", async () => { + const { tools } = createTaskTools(); + const del = tools.find((t) => t.definition.name === "delete_task")!; + await expect(del.executor.execute({ task_id: "999" })).rejects.toThrow('Task "999" not found'); + }); + + it("delete_task 应调用 onSave 和 sendEvent", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const sendEvent = vi.fn(); + const { tools } = createTaskTools({ onSave, sendEvent }); + const create = tools.find((t) => t.definition.name === "create_task")!; + const del = tools.find((t) => t.definition.name === "delete_task")!; + + await create.executor.execute({ subject: "Task" }); + onSave.mockClear(); + sendEvent.mockClear(); + + await del.executor.execute({ task_id: "1" }); + + expect(onSave).toHaveBeenCalledOnce(); + expect(onSave).toHaveBeenCalledWith([]); + expect(sendEvent).toHaveBeenCalledOnce(); + expect(sendEvent).toHaveBeenCalledWith({ + type: "task_update", + tasks: [], + }); + }); + + it("get_task 不应触发 onSave 或 sendEvent", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const sendEvent = vi.fn(); + const initial: Task[] = [{ id: "1", subject: "Existing", status: "pending", description: "Desc" }]; + const { tools } = createTaskTools({ initialTasks: initial, onSave, sendEvent }); + const get = tools.find((t) => t.definition.name === "get_task")!; + + await get.executor.execute({ task_id: "1" }); + + expect(onSave).not.toHaveBeenCalled(); + expect(sendEvent).not.toHaveBeenCalled(); + }); + + it("多实例应独立", async () => { + const instance1 = createTaskTools(); + const instance2 = createTaskTools(); + + const create1 = instance1.tools.find((t) => t.definition.name === "create_task")!; + const list2 = instance2.tools.find((t) => t.definition.name === "list_tasks")!; + + await create1.executor.execute({ subject: "Only in instance1" }); + + const result = JSON.parse((await list2.executor.execute({})) as string); + expect(result).toEqual([]); + expect(instance1.tasks.size).toBe(1); + expect(instance2.tasks.size).toBe(0); + }); +}); diff --git a/src/app/service/agent/core/tools/task_tools.ts b/src/app/service/agent/core/tools/task_tools.ts new file mode 100644 index 000000000..0545fd911 --- /dev/null +++ b/src/app/service/agent/core/tools/task_tools.ts @@ -0,0 +1,199 @@ +import type { ToolDefinition, ChatStreamEvent } from "@App/app/service/agent/core/types"; +import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; + +export type Task = { + id: string; + subject: string; + description?: string; + status: "pending" | "in_progress" | "completed"; +}; + +const CREATE_TASK_DEFINITION: ToolDefinition = { + name: "create_task", + description: "Create a new task. Returns the created task with an auto-assigned ID and status 'pending'.", + parameters: { + type: "object", + properties: { + subject: { + type: "string", + description: "Brief, actionable title in imperative form (e.g., 'Extract product prices from page')", + }, + description: { + type: "string", + description: "Detailed description including context and acceptance criteria", + }, + }, + required: ["subject"], + }, +}; + +const UPDATE_TASK_DEFINITION: ToolDefinition = { + name: "update_task", + description: "Update a task's status or details. Can change status, subject, and description.", + parameters: { + type: "object", + properties: { + task_id: { type: "string", description: "The task ID" }, + status: { + type: "string", + enum: ["pending", "in_progress", "completed"], + description: "New status for the task", + }, + subject: { type: "string", description: "New subject for the task" }, + description: { type: "string", description: "New description for the task" }, + }, + required: ["task_id"], + }, +}; + +const GET_TASK_DEFINITION: ToolDefinition = { + name: "get_task", + description: "Get a task's full details including description. list_tasks only returns id/subject/status.", + parameters: { + type: "object", + properties: { + task_id: { type: "string", description: "The task ID" }, + }, + required: ["task_id"], + }, +}; + +const DELETE_TASK_DEFINITION: ToolDefinition = { + name: "delete_task", + description: "Delete a task permanently.", + parameters: { + type: "object", + properties: { + task_id: { type: "string", description: "The task ID to delete" }, + }, + required: ["task_id"], + }, +}; + +const LIST_TASKS_DEFINITION: ToolDefinition = { + name: "list_tasks", + description: "List all tasks with their IDs, subjects, and statuses (without descriptions).", + parameters: { + type: "object", + properties: {}, + }, +}; + +export type TaskToolsOptions = { + // 初始任务列表(从持久化加载) + initialTasks?: Task[]; + // 任务变更时的持久化回调 + onSave?: (tasks: Task[]) => Promise; + // 任务变更时的事件推送回调(推送到 UI) + sendEvent?: (event: ChatStreamEvent) => void; +}; + +export function createTaskTools(options?: TaskToolsOptions): { + tools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>; + tasks: Map; +} { + const tasks = new Map(); + let nextId = 1; + + // 从持久化数据恢复 + if (options?.initialTasks) { + for (const task of options.initialTasks) { + tasks.set(task.id, task); + const numId = parseInt(task.id, 10); + if (!isNaN(numId) && numId >= nextId) { + nextId = numId + 1; + } + } + } + + // 持久化并推送事件 + const emitUpdate = async () => { + const taskList = Array.from(tasks.values()); + if (options?.onSave) { + await options.onSave(taskList); + } + if (options?.sendEvent) { + options.sendEvent({ + type: "task_update", + tasks: taskList.map((t) => ({ + id: t.id, + subject: t.subject, + status: t.status, + description: t.description, + })), + }); + } + }; + + const createExecutor: ToolExecutor = { + execute: async (args: Record) => { + const task: Task = { + id: String(nextId++), + subject: args.subject as string, + description: args.description as string | undefined, + status: "pending", + }; + tasks.set(task.id, task); + await emitUpdate(); + return JSON.stringify(task); + }, + }; + + const updateExecutor: ToolExecutor = { + execute: async (args: Record) => { + const task = tasks.get(args.task_id as string); + if (!task) { + throw new Error(`Task "${args.task_id}" not found`); + } + if (args.status) task.status = args.status as Task["status"]; + if (args.subject) task.subject = args.subject as string; + if (args.description !== undefined) task.description = args.description as string; + await emitUpdate(); + return JSON.stringify(task); + }, + }; + + const getExecutor: ToolExecutor = { + execute: async (args: Record) => { + const task = tasks.get(args.task_id as string); + if (!task) { + throw new Error(`Task "${args.task_id}" not found`); + } + return JSON.stringify(task); + }, + }; + + const deleteExecutor: ToolExecutor = { + execute: async (args: Record) => { + const taskId = args.task_id as string; + if (!tasks.has(taskId)) { + throw new Error(`Task "${taskId}" not found`); + } + tasks.delete(taskId); + await emitUpdate(); + return JSON.stringify({ deleted: true, task_id: taskId }); + }, + }; + + const listExecutor: ToolExecutor = { + execute: async () => { + const list = Array.from(tasks.values()).map((t) => ({ + id: t.id, + subject: t.subject, + status: t.status, + })); + return JSON.stringify(list); + }, + }; + + return { + tools: [ + { definition: CREATE_TASK_DEFINITION, executor: createExecutor }, + { definition: UPDATE_TASK_DEFINITION, executor: updateExecutor }, + { definition: GET_TASK_DEFINITION, executor: getExecutor }, + { definition: DELETE_TASK_DEFINITION, executor: deleteExecutor }, + { definition: LIST_TASKS_DEFINITION, executor: listExecutor }, + ], + tasks, + }; +} diff --git a/src/app/service/agent/core/tools/web_fetch.test.ts b/src/app/service/agent/core/tools/web_fetch.test.ts new file mode 100644 index 000000000..28a4b8a79 --- /dev/null +++ b/src/app/service/agent/core/tools/web_fetch.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { WebFetchExecutor, stripHtmlTags } from "./web_fetch"; + +// 通过 mockSender.sendMessage 控制 offscreen extractHtmlContent 的返回值 +let mockExtractReturnValue: string | null = null; +let mockExtractShouldThrow = false; + +describe("stripHtmlTags", () => { + it("should remove HTML tags", () => { + expect(stripHtmlTags("

Hello World

")).toBe("Hello World"); + }); + + it("should remove script and style tags with content", () => { + const html = "
text more
"; + expect(stripHtmlTags(html)).toBe("text more"); + }); + + it("should handle empty string", () => { + expect(stripHtmlTags("")).toBe(""); + }); +}); + +describe("WebFetchExecutor", () => { + const mockSender = { + sendMessage: vi.fn().mockImplementation(() => { + if (mockExtractShouldThrow) { + return Promise.reject(new Error("Offscreen unavailable")); + } + return Promise.resolve({ data: mockExtractReturnValue }); + }), + } as any; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + mockExtractReturnValue = null; + mockExtractShouldThrow = false; + mockSender.sendMessage.mockImplementation(() => { + if (mockExtractShouldThrow) { + return Promise.reject(new Error("Offscreen unavailable")); + } + return Promise.resolve({ data: mockExtractReturnValue }); + }); + }); + + it("should throw for missing url", async () => { + const executor = new WebFetchExecutor(mockSender); + await expect(executor.execute({})).rejects.toThrow("url is required"); + }); + + it("should throw for invalid url", async () => { + const executor = new WebFetchExecutor(mockSender); + await expect(executor.execute({ url: "not-a-url" })).rejects.toThrow("Invalid URL"); + }); + + it("should throw for non-http protocol", async () => { + const executor = new WebFetchExecutor(mockSender); + await expect(executor.execute({ url: "ftp://example.com" })).rejects.toThrow("Only http/https"); + }); + + it("should handle JSON response", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "application/json" }), + text: () => Promise.resolve('{"key":"value"}'), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://api.example.com/data" })) as string); + + expect(result.content_type).toBe("json"); + expect(JSON.parse(result.content)).toEqual({ key: "value" }); + expect(result.truncated).toBe(false); + }); + + it("should handle HTML response with offscreen extraction", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "text/html" }), + text: () => Promise.resolve("

Hello World long content here for testing

"), + }); + vi.stubGlobal("fetch", mockFetch); + mockExtractReturnValue = "Hello World long content here for testing extracted properly by offscreen"; + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com" })) as string); + + expect(result.content_type).toBe("html"); + expect(result.content).toContain("Hello World"); + }); + + it("should fallback to stripHtmlTags when extraction returns null", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "text/html" }), + text: () => Promise.resolve("

Simple text

"), + }); + vi.stubGlobal("fetch", mockFetch); + mockExtractReturnValue = null; + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com" })) as string); + + expect(result.content_type).toBe("text"); + expect(result.content).toBe("Simple text"); + }); + + it("should truncate content at max_length", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "text/plain" }), + text: () => Promise.resolve("a".repeat(200)), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com", max_length: 50 })) as string); + + expect(result.content.length).toBe(50); + expect(result.truncated).toBe(true); + }); + + it("should throw on HTTP error", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebFetchExecutor(mockSender); + await expect(executor.execute({ url: "https://example.com" })).rejects.toThrow("HTTP 404"); + }); + + it("should fallback to stripHtmlTags when offscreen extraction throws", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "text/html" }), + text: () => Promise.resolve("

Fallback content

"), + }); + vi.stubGlobal("fetch", mockFetch); + mockExtractShouldThrow = true; + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com" })) as string); + + expect(result.content_type).toBe("text"); + expect(result.content).toBe("Fallback content"); + }); + + it("should fallback to stripHtmlTags when extraction result is too short", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "text/html" }), + text: () => Promise.resolve("

Hi

"), + }); + vi.stubGlobal("fetch", mockFetch); + mockExtractReturnValue = "Hi"; // shorter than 50 chars + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com" })) as string); + + expect(result.content_type).toBe("text"); + expect(result.content).toBe("Hi"); + }); + + it("should handle invalid JSON with json content-type", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "application/json" }), + text: () => Promise.resolve("not valid json {{{"), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://api.example.com" })) as string); + + // Should fall back to text + expect(result.content_type).toBe("text"); + }); + + it("should handle empty content-type as unknown (try html extraction)", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({}), + text: () => + Promise.resolve( + "Long enough content for extraction to work properly and pass the threshold" + ), + }); + vi.stubGlobal("fetch", mockFetch); + mockExtractReturnValue = "Long enough content for extraction to work properly and pass the threshold"; + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com" })) as string); + + expect(result.content_type).toBe("html"); + expect(mockSender.sendMessage).toHaveBeenCalled(); + }); + + it("should handle text/plain content-type as plain text", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "text/plain" }), + text: () => Promise.resolve("Just plain text"), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com/file.txt" })) as string); + + expect(result.content_type).toBe("text"); + expect(result.content).toBe("Just plain text"); + expect(mockSender.sendMessage).not.toHaveBeenCalled(); + }); + + it("should pass User-Agent header and AbortSignal to fetch", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "text/plain" }), + text: () => Promise.resolve("hello"), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebFetchExecutor(mockSender); + await executor.execute({ url: "https://example.com" }); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com", { + headers: { "User-Agent": "Mozilla/5.0 (compatible; ScriptCat Agent)" }, + signal: expect.any(AbortSignal), + }); + }); + + it("should return final_url when response URL differs (redirect)", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + url: "https://example.com/redirected", + headers: new Headers({ "content-type": "text/plain" }), + text: () => Promise.resolve("content"), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com" })) as string); + + expect(result.final_url).toBe("https://example.com/redirected"); + }); + + it("should not include final_url when no redirect", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + url: "https://example.com", + headers: new Headers({ "content-type": "text/plain" }), + text: () => Promise.resolve("content"), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com" })) as string); + + expect(result.final_url).toBeUndefined(); + }); + + it("should not truncate by default", async () => { + const longContent = "x".repeat(15000); + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-type": "text/plain" }), + text: () => Promise.resolve(longContent), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebFetchExecutor(mockSender); + const result = JSON.parse((await executor.execute({ url: "https://example.com" })) as string); + + expect(result.content.length).toBe(15000); + expect(result.truncated).toBe(false); + }); +}); diff --git a/src/app/service/agent/core/tools/web_fetch.ts b/src/app/service/agent/core/tools/web_fetch.ts new file mode 100644 index 000000000..963de8e0b --- /dev/null +++ b/src/app/service/agent/core/tools/web_fetch.ts @@ -0,0 +1,141 @@ +import type { ToolDefinition } from "@App/app/service/agent/core/types"; +import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; +import type { MessageSend } from "@Packages/message/types"; +import { extractHtmlContent } from "@App/app/service/offscreen/client"; + +export const WEB_FETCH_DEFINITION: ToolDefinition = { + name: "web_fetch", + description: + "Fetch content from a URL and extract specific information via LLM. Text only — not suitable for binary downloads. " + + "Always provide a prompt describing what information you need — the raw page content will be processed by LLM to return only relevant information, saving context.", + parameters: { + type: "object", + properties: { + url: { type: "string", description: "The URL to fetch (http/https)" }, + prompt: { + type: "string", + description: + "Describe what information to extract/summarize from the fetched content. Required for efficient context usage.", + }, + max_length: { type: "number", description: "Max characters to return (no limit by default)" }, + }, + required: ["url", "prompt"], + }, +}; + +// 简单正则去 HTML 标签(降级方案) +export function stripHtmlTags(html: string): string { + return html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export class WebFetchExecutor implements ToolExecutor { + private summarize?: (content: string, prompt: string) => Promise; + + constructor( + private sender: MessageSend, + deps?: { summarize?: (content: string, prompt: string) => Promise } + ) { + this.summarize = deps?.summarize; + } + + async execute(args: Record): Promise { + const url = args.url as string; + const prompt = args.prompt as string | undefined; + const maxLength = args.max_length as number | undefined; + + if (!url) { + throw new Error("url is required"); + } + + // 校验 URL + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + throw new Error("Only http/https URLs are supported"); + } + + const response = await fetch(url, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; ScriptCat Agent)" }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + // 检测重定向:最终 URL 与请求 URL 不同 + const finalUrl = response.url && response.url !== url ? response.url : undefined; + + const contentType = response.headers.get("content-type") || ""; + const text = await response.text(); + let content: string; + let detectedType: string; + + // a) Content-Type 含 json → 尝试 JSON.parse + if (contentType.includes("json")) { + try { + const parsed = JSON.parse(text); + content = JSON.stringify(parsed, null, 2); + detectedType = "json"; + } catch { + // 不是有效 JSON,当作纯文本 + content = stripHtmlTags(text); + detectedType = "text"; + } + } + // b) Content-Type 含 html 或未知 → 送 Offscreen extractHtmlContent + else if (contentType.includes("html") || !contentType) { + try { + const extracted = await extractHtmlContent(this.sender, text); + if (extracted && extracted.length > 50) { + content = extracted; + detectedType = "html"; + } else { + // 提取结果太短,降级到纯文本 + content = stripHtmlTags(text); + detectedType = "text"; + } + } catch { + // Offscreen 提取失败,降级 + content = stripHtmlTags(text); + detectedType = "text"; + } + } + // c) 其他类型 → 纯文本 + else { + content = text; + detectedType = "text"; + } + + // 截断(仅当显式传入 max_length 时) + let truncated = maxLength != null && content.length > maxLength; + if (truncated) { + content = content.slice(0, maxLength); + } + + // LLM 摘要 + if (prompt && this.summarize) { + content = await this.summarize(content, prompt); + truncated = false; + } + + const result: Record = { + url, + content_type: detectedType, + content, + truncated, + }; + if (finalUrl) { + result.final_url = finalUrl; + } + return JSON.stringify(result); + } +} diff --git a/src/app/service/agent/core/tools/web_search.test.ts b/src/app/service/agent/core/tools/web_search.test.ts new file mode 100644 index 000000000..1f3050c32 --- /dev/null +++ b/src/app/service/agent/core/tools/web_search.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { WebSearchExecutor } from "./web_search"; +import type { SearchConfigRepo } from "./search_config"; + +// mockExtractResults 存储 mock 返回值,通过 mockSender.sendMessage 传递 +let mockExtractReturnValue: any[] = []; + +describe("WebSearchExecutor", () => { + const mockSender = { + sendMessage: vi.fn().mockImplementation(() => Promise.resolve({ data: mockExtractReturnValue })), + } as any; + + const createMockConfigRepo = (engine: "bing" | "duckduckgo" | "baidu" | "google_custom"): SearchConfigRepo => ({ + getConfig: vi.fn().mockResolvedValue({ + engine, + googleApiKey: engine === "google_custom" ? "test-key" : undefined, + googleCseId: engine === "google_custom" ? "test-cse" : undefined, + }), + saveConfig: vi.fn(), + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + mockExtractReturnValue = []; + mockSender.sendMessage.mockImplementation(() => Promise.resolve({ data: mockExtractReturnValue })); + }); + + it("should throw for missing query", async () => { + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("duckduckgo")); + await expect(executor.execute({})).rejects.toThrow("query is required"); + }); + + it("should search DuckDuckGo and return results", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve("
...
"), + }); + vi.stubGlobal("fetch", mockFetch); + + mockExtractReturnValue = [ + { title: "Result 1", url: "https://example.com/1", snippet: "Snippet 1" }, + { title: "Result 2", url: "https://example.com/2", snippet: "Snippet 2" }, + ]; + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("duckduckgo")); + const result = JSON.parse((await executor.execute({ query: "test search" })) as string); + + expect(result).toHaveLength(2); + expect(result[0].title).toBe("Result 1"); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("html.duckduckgo.com"), expect.any(Object)); + }); + + it("should respect max_results", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + }); + vi.stubGlobal("fetch", mockFetch); + + const manyResults = Array.from({ length: 10 }, (_, i) => ({ + title: `R${i}`, + url: `https://example.com/${i}`, + snippet: `S${i}`, + })); + mockExtractReturnValue = manyResults; + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("duckduckgo")); + const result = JSON.parse((await executor.execute({ query: "test", max_results: 3 })) as string); + + expect(result).toHaveLength(3); + }); + + it("should cap max_results at 10", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + }); + vi.stubGlobal("fetch", mockFetch); + + const manyResults = Array.from({ length: 15 }, (_, i) => ({ + title: `R${i}`, + url: `https://example.com/${i}`, + snippet: `S${i}`, + })); + mockExtractReturnValue = manyResults; + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("duckduckgo")); + const result = JSON.parse((await executor.execute({ query: "test", max_results: 20 })) as string); + + expect(result).toHaveLength(10); + }); + + it("should search Google Custom Search API", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + items: [{ title: "Google Result", link: "https://example.com", snippet: "Google snippet" }], + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("google_custom")); + const result = JSON.parse((await executor.execute({ query: "google test" })) as string); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Google Result"); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("googleapis.com/customsearch"), expect.any(Object)); + }); + + it("should throw when DuckDuckGo returns error", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("duckduckgo")); + await expect(executor.execute({ query: "test" })).rejects.toThrow("DuckDuckGo search failed"); + }); + + it("should throw when Google API returns error", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 403, + text: () => Promise.resolve("Forbidden"), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("google_custom")); + await expect(executor.execute({ query: "test" })).rejects.toThrow("Google search failed: HTTP 403"); + }); + + it("should throw when Google config is missing API key", async () => { + const configRepo: SearchConfigRepo = { + getConfig: vi.fn().mockResolvedValue({ + engine: "google_custom", + googleApiKey: "", + googleCseId: "", + }), + saveConfig: vi.fn(), + }; + + const executor = new WebSearchExecutor(mockSender, configRepo); + await expect(executor.execute({ query: "test" })).rejects.toThrow("Google Custom Search requires API Key"); + }); + + it("should handle Google API returning no items", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({}), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("google_custom")); + const result = JSON.parse((await executor.execute({ query: "test" })) as string); + + expect(result).toEqual([]); + }); + + it("should pass AbortSignal to DuckDuckGo fetch for 15s timeout", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + }); + vi.stubGlobal("fetch", mockFetch); + mockExtractReturnValue = []; + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("duckduckgo")); + await executor.execute({ query: "test" }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("html.duckduckgo.com"), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + it("should pass AbortSignal to Google fetch for 15s timeout", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("google_custom")); + await executor.execute({ query: "test" }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("googleapis.com/customsearch"), + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + it("should return warning when extractSearchResults times out", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve("
...
"), + }); + vi.stubGlobal("fetch", mockFetch); + + // 模拟 extractSearchResults 抛出超时错误(等效于 Promise.race 超时) + mockSender.sendMessage.mockImplementation(() => Promise.reject(new Error("extract timeout"))); + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("duckduckgo")); + const result = JSON.parse((await executor.execute({ query: "test" })) as string); + + expect(result.results).toEqual([]); + expect(result.warning).toContain("extraction failed"); + expect(result.warning).toContain("duckduckgo"); + }); + + it("should search Bing and return results", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve( + "
  • Bing Result

    Bing snippet

  • " + ), + }); + vi.stubGlobal("fetch", mockFetch); + + mockExtractReturnValue = [{ title: "Bing Result", url: "https://example.com", snippet: "Bing snippet" }]; + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("bing")); + const result = JSON.parse((await executor.execute({ query: "bing test" })) as string); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Bing Result"); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("bing.com/search"), expect.any(Object)); + }); + + it("should throw when Bing returns error", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("bing")); + await expect(executor.execute({ query: "test" })).rejects.toThrow("Bing search failed"); + }); + + it("should return warning when Bing extraction fails", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + }); + vi.stubGlobal("fetch", mockFetch); + + mockSender.sendMessage.mockImplementation(() => Promise.reject(new Error("extract timeout"))); + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("bing")); + const result = JSON.parse((await executor.execute({ query: "test" })) as string); + + expect(result.results).toEqual([]); + expect(result.warning).toContain("extraction failed"); + expect(result.warning).toContain("bing"); + }); + + it("should search Baidu and return results", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => + Promise.resolve( + "

    Baidu Result

    Baidu snippet
    " + ), + }); + vi.stubGlobal("fetch", mockFetch); + + mockExtractReturnValue = [{ title: "Baidu Result", url: "https://example.com", snippet: "Baidu snippet" }]; + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("baidu")); + const result = JSON.parse((await executor.execute({ query: "百度测试" })) as string); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Baidu Result"); + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("baidu.com/s"), expect.any(Object)); + }); + + it("should throw when Baidu returns error", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 503, + }); + vi.stubGlobal("fetch", mockFetch); + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("baidu")); + await expect(executor.execute({ query: "test" })).rejects.toThrow("Baidu search failed"); + }); + + it("should default to 5 results when max_results not specified", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + }); + vi.stubGlobal("fetch", mockFetch); + + const manyResults = Array.from({ length: 8 }, (_, i) => ({ + title: `R${i}`, + url: `https://example.com/${i}`, + snippet: `S${i}`, + })); + mockExtractReturnValue = manyResults; + + const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("duckduckgo")); + const result = JSON.parse((await executor.execute({ query: "test" })) as string); + + expect(result).toHaveLength(5); + }); +}); diff --git a/src/app/service/agent/core/tools/web_search.ts b/src/app/service/agent/core/tools/web_search.ts new file mode 100644 index 000000000..2d8f2561d --- /dev/null +++ b/src/app/service/agent/core/tools/web_search.ts @@ -0,0 +1,170 @@ +import type { ToolDefinition } from "@App/app/service/agent/core/types"; +import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; +import type { MessageSend } from "@Packages/message/types"; +import type { SearchConfigRepo } from "./search_config"; +import { extractSearchResults, extractBingResults, extractBaiduResults } from "@App/app/service/offscreen/client"; +import { withTimeout } from "@App/pkg/utils/with_timeout"; + +export const WEB_SEARCH_DEFINITION: ToolDefinition = { + name: "web_search", + description: + "Search the web for information. Returns a list of results with title, URL, and snippet. Use this to find up-to-date information.", + parameters: { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + max_results: { type: "number", description: "Max results to return (default 5, max 10)" }, + }, + required: ["query"], + }, +}; + +/** 格式化搜索结果,区分"无结果"和"提取失败" */ +function formatSearchResults( + results: Array<{ title: string; url: string; snippet: string }>, + extractionFailed: boolean, + engine: string +): string { + if (extractionFailed && results.length === 0) { + return JSON.stringify({ + results: [], + warning: `Result extraction failed or timed out (engine: ${engine}). Try a different search engine or rephrase the query.`, + }); + } + return JSON.stringify(results); +} + +export class WebSearchExecutor implements ToolExecutor { + constructor( + private sender: MessageSend, + private configRepo: SearchConfigRepo + ) {} + + async execute(args: Record): Promise { + const query = args.query as string; + const maxResults = Math.min((args.max_results as number) || 5, 10); + + if (!query) { + throw new Error("query is required"); + } + + const config = await this.configRepo.getConfig(); + + switch (config.engine) { + case "google_custom": + return this.searchGoogle(query, maxResults, config.googleApiKey || "", config.googleCseId || ""); + case "duckduckgo": + return this.searchDuckDuckGo(query, maxResults); + case "baidu": + return this.searchBaidu(query, maxResults); + case "bing": + default: + return this.searchBing(query, maxResults); + } + } + + private async searchDuckDuckGo(query: string, maxResults: number): Promise { + const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`; + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; ScriptCat Agent)", + }, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + throw new Error(`DuckDuckGo search failed: HTTP ${response.status}`); + } + + const html = await response.text(); + + // extractSearchResults 走 Offscreen 通道,加 10s 超时防卡死 + let results: Awaited>; + let extractionFailed = false; + try { + results = await withTimeout(extractSearchResults(this.sender, html), 10_000, () => new Error("extract timeout")); + } catch { + results = []; + extractionFailed = true; + } + + return formatSearchResults(results.slice(0, maxResults), extractionFailed, "duckduckgo"); + } + + private async searchBing(query: string, maxResults: number): Promise { + const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}`; + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; ScriptCat Agent)", + }, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + throw new Error(`Bing search failed: HTTP ${response.status}`); + } + + const html = await response.text(); + + let results: Awaited>; + let extractionFailed = false; + try { + results = await withTimeout(extractBingResults(this.sender, html), 10_000, () => new Error("extract timeout")); + } catch { + results = []; + extractionFailed = true; + } + + return formatSearchResults(results.slice(0, maxResults), extractionFailed, "bing"); + } + + private async searchBaidu(query: string, maxResults: number): Promise { + const url = `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}`; + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (compatible; ScriptCat Agent)", + }, + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + throw new Error(`Baidu search failed: HTTP ${response.status}`); + } + + const html = await response.text(); + + let results: Awaited>; + let extractionFailed = false; + try { + results = await withTimeout(extractBaiduResults(this.sender, html), 10_000, () => new Error("extract timeout")); + } catch { + results = []; + extractionFailed = true; + } + + return formatSearchResults(results.slice(0, maxResults), extractionFailed, "baidu"); + } + + private async searchGoogle(query: string, maxResults: number, apiKey: string, cseId: string): Promise { + if (!apiKey || !cseId) { + throw new Error("Google Custom Search requires API Key and CSE ID. Configure them in Agent Tool Settings."); + } + + const url = `https://www.googleapis.com/customsearch/v1?key=${encodeURIComponent(apiKey)}&cx=${encodeURIComponent(cseId)}&q=${encodeURIComponent(query)}&num=${maxResults}`; + const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Google search failed: HTTP ${response.status} ${text.slice(0, 200)}`); + } + + const data = await response.json(); + const results = (data.items || []).map((item: any) => ({ + title: item.title || "", + url: item.link || "", + snippet: item.snippet || "", + })); + + return JSON.stringify(results.slice(0, maxResults)); + } +} diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts new file mode 100644 index 000000000..b93f75ef2 --- /dev/null +++ b/src/app/service/agent/core/types.ts @@ -0,0 +1,631 @@ +// ---- 通用类型 ---- + +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +// ---- ContentBlock 多模态内容类型 ---- + +export type TextBlock = { type: "text"; text: string }; +export type ImageBlock = { type: "image"; attachmentId: string; mimeType: string; name?: string }; +export type FileBlock = { type: "file"; attachmentId: string; mimeType: string; name: string; size?: number }; +export type AudioBlock = { type: "audio"; attachmentId: string; mimeType: string; name?: string; durationMs?: number }; + +export type ContentBlock = TextBlock | ImageBlock | FileBlock | AudioBlock; +export type MessageContent = string | ContentBlock[]; + +export type Conversation = { + id: string; + title: string; + modelId: string; + system?: string; + skills?: "auto" | string[]; + enableTools?: boolean; // 是否携带 tools,默认 true;图片生成模型需关闭 + createtime: number; + updatetime: number; +}; + +export type MessageRole = "user" | "assistant" | "system" | "tool"; + +export type Attachment = { + id: string; + type: "image" | "file" | "audio"; + name: string; // 文件名 + mimeType: string; // "image/jpeg", "application/zip" 等 + size?: number; // 字节数 + // 数据不内联存储,通过 id 从 OPFS 加载 +}; + +export type AttachmentData = { + type: "image" | "file" | "audio"; + name: string; + mimeType: string; + data: string | Blob; // base64/data URL 或 Blob +}; + +export type ToolResultWithAttachments = { + content: string; // 文本结果(发给 LLM) + attachments: AttachmentData[]; // 附件数据(仅存储+展示) +}; + +// 子代理单轮消息(持久化用) +export type SubAgentMessage = { + content: string; + thinking?: string; + toolCalls: ToolCall[]; +}; + +// 子代理执行详情(持久化到 ToolCall) +export type SubAgentDetails = { + agentId: string; + description: string; + subAgentType?: string; + messages: SubAgentMessage[]; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; +}; + +export type ToolCall = { + id: string; + name: string; + arguments: string; + result?: string; + attachments?: Attachment[]; + subAgentDetails?: SubAgentDetails; + status?: "pending" | "running" | "completed" | "error"; +}; + +export type ThinkingBlock = { + content: string; +}; + +export type ChatMessage = { + id: string; + conversationId: string; + role: MessageRole; + content: MessageContent; + thinking?: ThinkingBlock; + toolCalls?: ToolCall[]; + // tool 角色的消息需要关联到对应的 tool_call + toolCallId?: string; + error?: string; + warning?: string; + modelId?: string; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + durationMs?: number; + firstTokenMs?: number; + parentId?: string; + createtime: number; +}; + +// Service Worker -> UI/Sandbox 的流式事件(通过 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: "tool_call_complete"; id: string; result: string; attachments?: Attachment[] } + | { type: "content_block_start"; block: Omit } + | { type: "content_block_complete"; block: ImageBlock | FileBlock | AudioBlock; data?: string } + | { type: "ask_user"; id: string; question: string; options?: string[]; multiple?: boolean } + | { type: "system_warning"; message: string } + | { type: "sub_agent_event"; agentId: string; description: string; subAgentType?: string; event: ChatStreamEvent } + | { + type: "task_update"; + tasks: Array<{ + id: string; + subject: string; + status: "pending" | "in_progress" | "completed"; + description?: string; + }>; + } + | { type: "new_message" } + | { + type: "done"; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + durationMs?: number; + } + | { type: "error"; message: string; errorCode?: string } + | { type: "retry"; attempt: number; maxRetries: number; error: string; delayMs: number } + | { type: "compact_done"; summary: string; originalCount: number } + | { + type: "sync"; + streamingMessage?: { content: string; thinking?: string; toolCalls: ToolCall[] }; + pendingAskUser?: { id: string; question: string; options?: string[]; multiple?: boolean }; + tasks: Array<{ + id: string; + subject: string; + status: "pending" | "in_progress" | "completed"; + description?: string; + }>; + status: "running" | "done" | "error"; + }; + +// UI -> Service Worker 的聊天请求 +export type ChatRequest = { + conversationId: string; + modelId: string; + messages: Array<{ role: MessageRole; content: MessageContent; toolCallId?: string; toolCalls?: ToolCall[] }>; + tools?: ToolDefinition[]; + cache?: boolean; // 是否启用 prompt caching(Anthropic),默认 true。短对话(如子 agent)可关闭以节省开销 +}; + +// ---- Agent 模型配置 ---- + +export type AgentModelConfig = { + id: string; // 唯一标识 + name: string; // 用户自定义名称(如 "GPT-4o", "Claude Sonnet") + provider: "openai" | "anthropic" | "zhipu"; + apiBaseUrl: string; + apiKey: string; + model: string; + maxTokens?: number; // 最大输出 token 数,不设置则由 API 端决定 + contextWindow?: number; // 最大上下文 token 数(输入+输出),如 128000、200000 + availableModels?: string[]; // 缓存从 API 获取的可用模型列表 + supportsVision?: boolean; // 用户手动标记是否支持视觉输入 + supportsImageOutput?: boolean; // 用户手动标记是否支持图片输出 +}; + +// 隐藏 apiKey 的安全版模型配置,暴露给用户脚本 +export type AgentModelSafeConfig = Omit; + +// CAT.agent.model API 请求 +export type ModelApiRequest = + | { action: "list"; scriptUuid: string } + | { action: "get"; id: string; scriptUuid: string } + | { action: "getDefault"; scriptUuid: string } + | { action: "getSummary"; scriptUuid: string }; + +// ---- CAT.agent.conversation 用户脚本 API 类型 ---- + +// 工具定义(用户脚本传入的格式) +export type ToolDefinition = { + name: string; + description: string; + parameters: Record; // JSON Schema +}; + +// 命令处理器类型 +export type CommandHandler = (args: string, conv: any) => Promise; + +// conversation.create() 的参数 +export type ConversationCreateOptions = { + id?: string; + system?: string; + model?: string; // modelId,不传则使用默认模型 + maxIterations?: number; // tool calling 最大循环次数,默认 20 + skills?: "auto" | string[]; // 加载的 Skill,"auto" 加载全部,数组指定名称 + tools?: Array) => Promise }>; + commands?: Record; // 自定义命令处理器,以 / 开头 + ephemeral?: boolean; // 临时会话:不持久化、不加载内置资源、工具由脚本提供 + cache?: boolean; // 是否启用 prompt caching,默认 true + background?: boolean; // 后台运行:UI 断开后继续执行,默认 false +}; + +// conv.chat() 的参数 +export type ChatOptions = { + tools?: Array) => Promise }>; +}; + +// conv.chat() 的返回值 +export type ChatReply = { + content: MessageContent; + thinking?: string; + toolCalls?: ToolCall[]; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + command?: boolean; // 标识该回复来自命令处理 +}; + +// conv.chatStream() 的流式 chunk +export type StreamChunk = { + type: "content_delta" | "thinking_delta" | "tool_call" | "content_block" | "done" | "error"; + content?: string; + block?: ContentBlock; + toolCall?: ToolCall; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + error?: string; + /** 错误分类码:"rate_limit" | "auth" | "tool_timeout" | "max_iterations" | "api_error" */ + errorCode?: string; + command?: boolean; // 标识该 chunk 来自命令处理 +}; + +// ---- 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 打包的脚本名称(scripts/ 目录下) + referenceNames: string[]; // 参考资料名称(references/ 目录下) + hasConfig?: boolean; // 是否有 config 字段声明 + enabled?: boolean; // 是否启用,默认 true(undefined 视为 true) + installtime: number; + updatetime: number; +}; + +// SKILL.md frontmatter 解析结果 +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 参考资料 +export type SkillReference = { + name: string; + content: string; +}; + +// CAT.agent.skills API 请求 +export type SkillApiRequest = + | { action: "list"; scriptUuid: string } + | { action: "get"; name: string; scriptUuid: string } + | { + action: "install"; + skillMd: string; + scripts?: Array<{ name: string; code: string }>; + references?: Array<{ name: string; content: string }>; + scriptUuid: string; + } + | { action: "remove"; name: string; scriptUuid: string } + | { action: "call"; skillName: string; scriptName: string; params?: Record; scriptUuid: string }; + +// CAT.agent.opfs API 请求 +export type OPFSApiRequest = + | { action: "write"; path: string; content: string | Blob; scriptUuid: string } + | { action: "read"; path: string; format?: "text" | "bloburl" | "blob"; scriptUuid: string } + | { action: "readAttachment"; id: string; scriptUuid: string } + | { action: "list"; path?: string; scriptUuid: string } + | { action: "delete"; path: string; scriptUuid: string }; + +// ---- Skill Script 类型 ---- + +export type SkillScriptParam = { + name: string; + type: "string" | "number" | "boolean"; + required: boolean; + description: string; + enum?: string[]; +}; + +export type SkillScriptMetadata = { + name: string; + description: string; + params: SkillScriptParam[]; + grants: string[]; + requires: string[]; + timeout?: number; // 自定义超时时间(秒) +}; + +// OPFS 中存储的 Skill Script 记录 +export type SkillScriptRecord = { + id: string; // UUID,用于 OPFS data 文件名,避免 name 转文件名时的碰撞 + name: string; + description: string; + params: SkillScriptParam[]; + grants: string[]; + requires?: string[]; // @require URL 列表 + timeout?: number; // 自定义超时时间(秒) + code: string; // 完整代码(含元数据头) + sourceScriptUuid?: string; // 安装来源脚本的 UUID + sourceScriptName?: string; // 安装来源脚本的名称 + installtime: number; + updatetime: number; +}; + +// ---- CAT.agent.dom 类型 ---- + +export type TabInfo = { + tabId: number; + url: string; + title: string; + active: boolean; + windowId: number; + discarded: boolean; +}; + +export type ActionResult = { + success: boolean; + navigated?: boolean; + url?: string; + newTab?: { tabId: number; url: string }; +}; + +export type PageContent = { + title: string; + url: string; + html: string; + truncated?: boolean; + totalLength?: number; +}; + +export type ReadPageOptions = { + tabId?: number; + selector?: string; + maxLength?: number; + removeTags?: string[]; // 要移除的标签/选择器,如 ["script", "style", "svg"] +}; + +export type DomActionOptions = { + tabId?: number; + trusted?: boolean; +}; + +export type WaitForOptions = { + tabId?: number; + timeout?: number; +}; + +export type ScreenshotOptions = { + tabId?: number; + quality?: number; + fullPage?: boolean; + selector?: string; // CSS 选择器,截取指定元素区域 + saveTo?: string; // OPFS workspace 相对路径,截图后保存二进制 +}; + +export type ScreenshotResult = { + dataUrl: string; // 原始 data URL + path?: string; // saveTo 时返回的 OPFS 路径 + size?: number; // saveTo 时返回的文件大小(字节) +}; + +export type NavigateOptions = { + tabId?: number; + waitUntil?: boolean; + timeout?: number; +}; + +export type ScrollDirection = "up" | "down" | "top" | "bottom"; + +export type ScrollOptions = { + tabId?: number; + selector?: string; +}; + +export type ScrollResult = { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + atBottom: boolean; +}; + +export type NavigateResult = { + tabId: number; + url: string; + title: string; +}; + +export type WaitForResult = { + found: boolean; + element?: { + selector: string; + tag: string; + text: string; + role?: string; + type?: string; + visible: boolean; + }; +}; + +// GM API 请求类型 +export type ExecuteScriptOptions = { + tabId?: number; +}; + +export type MonitorResult = { + dialogs: Array<{ type: string; message: string }>; + addedNodes: Array<{ tag: string; id?: string; class?: string; role?: string; text: string }>; +}; + +export type MonitorStatus = { + hasChanges: boolean; + dialogCount: number; + nodeCount: number; +}; + +export type DomApiRequest = + | { action: "listTabs"; scriptUuid: string } + | { action: "navigate"; url: string; options?: NavigateOptions; scriptUuid: string } + | { action: "readPage"; options?: ReadPageOptions; scriptUuid: string } + | { action: "screenshot"; options?: ScreenshotOptions; scriptUuid: string } + | { action: "click"; selector: string; options?: DomActionOptions; scriptUuid: string } + | { action: "fill"; selector: string; value: string; options?: DomActionOptions; scriptUuid: string } + | { action: "scroll"; direction: ScrollDirection; options?: ScrollOptions; scriptUuid: string } + | { action: "waitFor"; selector: string; options?: WaitForOptions; scriptUuid: string } + | { action: "executeScript"; code: string; options?: ExecuteScriptOptions; scriptUuid: string } + | { action: "startMonitor"; tabId: number; scriptUuid: string } + | { action: "stopMonitor"; tabId: number; scriptUuid: string } + | { action: "peekMonitor"; tabId: number; scriptUuid: string }; + +// ---- MCP 类型 ---- + +// MCP 服务器配置 +export type MCPServerConfig = { + id: string; + name: string; + url: string; // Streamable HTTP endpoint + apiKey?: string; // 可选认证 + headers?: Record; // 自定义请求头 + enabled: boolean; + createtime: number; + updatetime: number; +}; + +// MCP 工具(从服务器 tools/list 获取) +export type MCPTool = { + serverId: string; + name: string; + description?: string; + inputSchema: Record; // JSON Schema +}; + +// MCP 资源(从服务器 resources/list 获取) +export type MCPResource = { + serverId: string; + uri: string; + name: string; + description?: string; + mimeType?: string; +}; + +// MCP 提示词模板(从服务器 prompts/list 获取) +export type MCPPrompt = { + serverId: string; + name: string; + description?: string; + arguments?: Array<{ + name: string; + description?: string; + required?: boolean; + }>; +}; + +// MCP 提示词消息(prompts/get 返回) +export type MCPPromptMessage = { + role: "user" | "assistant"; + content: + | { type: "text"; text: string } + | { type: "resource"; resource: { uri: string; text: string; mimeType?: string } }; +}; + +// CAT.agent.mcp API 请求 +// scriptUuid 仅在 GM API 层用于权限校验,UI 直接调用时可省略 +export type MCPApiRequest = + | { action: "listServers"; scriptUuid?: string } + | { action: "getServer"; id: string; scriptUuid?: string } + | { + action: "addServer"; + config: Omit; + scriptUuid?: string; + } + | { action: "updateServer"; id: string; config: Partial; scriptUuid?: string } + | { action: "removeServer"; id: string; scriptUuid?: string } + | { action: "listTools"; serverId: string; scriptUuid?: string } + | { action: "listResources"; serverId: string; scriptUuid?: string } + | { action: "readResource"; serverId: string; uri: string; scriptUuid?: string } + | { action: "listPrompts"; serverId: string; scriptUuid?: string } + | { + action: "getPrompt"; + serverId: string; + name: string; + args?: Record; + scriptUuid?: string; + } + | { action: "testConnection"; id: string; scriptUuid?: string }; + +// ---- Agent 定时任务类型 ---- + +export type AgentTask = { + id: string; + name: string; + crontab: string; // cron 表达式(复用 cron.ts 格式) + mode: "internal" | "event"; // internal: SW 自主执行; event: 通知脚本 + enabled: boolean; + notify: boolean; // 是否通过 chrome.notifications 通知 + + // --- internal 模式字段 --- + prompt?: string; // 每次触发发送的消息 + modelId?: string; // 使用的模型 ID + conversationId?: string; // 可选:续接已有对话 + skills?: "auto" | string[]; + maxIterations?: number; // 工具循环上限,默认 10 + + // --- event 模式字段 --- + sourceScriptUuid?: string; // 创建任务的脚本 UUID + + // --- 运行状态 --- + lastruntime?: number; + nextruntime?: number; + lastRunStatus?: "success" | "error"; + lastRunError?: string; + createtime: number; + updatetime: number; +}; + +export type AgentTaskTrigger = { + taskId: string; + name: string; + crontab: string; + triggeredAt: number; +}; + +export type AgentTaskRun = { + id: string; + taskId: string; + conversationId?: string; // internal 模式才有 + starttime: number; + endtime?: number; + status: "running" | "success" | "error"; + error?: string; + usage?: { inputTokens: number; outputTokens: number }; +}; + +export type AgentTaskApiRequest = + | { action: "list" } + | { action: "get"; id: string } + | { action: "create"; task: Omit } + | { action: "update"; id: string; task: Partial } + | { action: "delete"; id: string } + | { action: "enable"; id: string; enabled: boolean } + | { action: "runNow"; id: string } + | { action: "listRuns"; taskId: string; limit?: number } + | { action: "clearRuns"; taskId: string }; + +// Sandbox -> Service Worker 的 conversation API 请求 +export type ConversationApiRequest = + | { action: "create"; options: ConversationCreateOptions; scriptUuid: string } + | { action: "get"; id: string; scriptUuid: string } + | { + action: "chat"; + conversationId: string; + message: MessageContent; + tools?: ToolDefinition[]; + scriptUuid: string; + // ephemeral 会话专用字段 + ephemeral?: boolean; + messages?: Array<{ role: MessageRole; content: MessageContent; toolCallId?: string; toolCalls?: ToolCall[] }>; + system?: string; + modelId?: string; + } + | { action: "getMessages"; conversationId: string; scriptUuid: string } + | { action: "save"; conversationId: string; scriptUuid: string } + | { action: "clearMessages"; conversationId: string; scriptUuid: string }; diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts new file mode 100644 index 000000000..d337bf316 --- /dev/null +++ b/src/app/service/agent/service_worker/agent.ts @@ -0,0 +1,448 @@ +import type { Group, IGetSender } from "@Packages/message/server"; +import type { MessageSend } from "@Packages/message/types"; +import type { + AgentModelConfig, + AgentModelSafeConfig, + ChatRequest, + ChatStreamEvent, + ConversationApiRequest, + ToolDefinition, + DomApiRequest, + SkillApiRequest, + SkillMetadata, + SkillRecord, + SkillSummary, + MessageContent, + AgentTaskApiRequest, + ModelApiRequest, + OPFSApiRequest, + MCPApiRequest, +} from "@App/app/service/agent/core/types"; +import { agentChatRepo } from "@App/app/repo/agent_chat"; +import type { AgentModelRepo } from "@App/app/repo/agent_model"; +import type { SkillRepo } from "@App/app/repo/skill_repo"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { ToolRegistry } from "@App/app/service/agent/core/tool_registry"; +import { SKILL_SCRIPT_UUID_PREFIX } from "@App/app/service/agent/core/skill_script_executor"; +import { CompactService } from "./compact_service"; +import { LLMClient } from "./llm_client"; +import { ToolLoopOrchestrator } from "./tool_loop_orchestrator"; +import { AgentDomService } from "./dom"; +import { MCPService } from "./mcp"; +import { type ResourceService } from "@App/app/service/service_worker/resource"; +import { SkillService } from "./skill_service"; +import { AgentTaskService } from "./task_service"; +import { AgentModelService } from "./model_service"; +import { AgentTaskRepo, AgentTaskRunRepo } from "@App/app/repo/agent_task"; +import { AgentTaskScheduler } from "@App/app/service/agent/core/task_scheduler"; +import { WEB_FETCH_DEFINITION, WebFetchExecutor } from "@App/app/service/agent/core/tools/web_fetch"; +import { WEB_SEARCH_DEFINITION, WebSearchExecutor } from "@App/app/service/agent/core/tools/web_search"; +import { SearchConfigRepo, type SearchEngineConfig } from "@App/app/service/agent/core/tools/search_config"; +import { SubAgentService } from "./sub_agent_service"; +import { BackgroundSessionManager, type RunningConversation } from "./background_session_manager"; +import { createOPFSTools, setCreateBlobUrlFn } from "@App/app/service/agent/core/tools/opfs_tools"; +import { createObjectURL } from "@App/app/service/offscreen/client"; +import { AgentOPFSService } from "./opfs_service"; +import { executeSkillScript } from "@App/app/service/offscreen/client"; +import { createTabTools } from "@App/app/service/agent/core/tools/tab_tools"; +import { ChatService } from "./chat_service"; + +// 保留对外 API(测试文件直接从 "./agent" import 这三个函数) +export { isRetryableError, withRetry, classifyErrorCode } from "./retry_utils"; + +export class AgentService { + private toolRegistry = new ToolRegistry(); + // Skill 相关功能委托给 SkillService + private skillService!: SkillService; + + // 测试兼容性:透传访问 SkillService 内部字段 + // (测试通过 (service as any).skillCache / .skillRepo 读写) + private get skillCache() { + return this.skillService.skillCache; + } + private set skillCache(v: Map) { + this.skillService.skillCache = v; + } + private get skillRepo() { + return this.skillService.skillRepo; + } + private set skillRepo(v: SkillRepo) { + this.skillService.skillRepo = v; + } + + // 模型管理委托给 AgentModelService + private modelService!: AgentModelService; + + // 测试兼容性:透传访问 AgentModelService 内部 modelRepo + // (测试通过 (service as any).modelRepo 读写) + private get modelRepo() { + return this.modelService.modelRepo; + } + private set modelRepo(repo: AgentModelRepo) { + this.modelService.modelRepo = repo; + } + + private domService = new AgentDomService(); + private mcpService!: MCPService; + // OPFS API 处理委托给 AgentOPFSService + private opfsService!: AgentOPFSService; + private taskRepo = new AgentTaskRepo(); + private taskRunRepo = new AgentTaskRunRepo(); + private taskScheduler!: AgentTaskScheduler; + // 定时任务逻辑委托给 AgentTaskService + private agentTaskService!: AgentTaskService; + private searchConfigRepo = new SearchConfigRepo(); + // 后台运行的会话注册表(委托给 BackgroundSessionManager) + private bgSessionManager = new BackgroundSessionManager(); + // 测试兼容性:透传访问 BackgroundSessionManager 内部注册表 + // (测试通过 (service as any).runningConversations 读写) + private get runningConversations() { + return (this.bgSessionManager as any).runningConversations as Map; + } + // 子代理编排逻辑委托给 SubAgentService + private subAgentService: SubAgentService; + // 上下文压缩逻辑委托给 CompactService + private compactService!: CompactService; + // LLM HTTP 调用(流式、重试、图片保存)委托给 LLMClient + private llmClient!: LLMClient; + // Tool calling 循环编排委托给 ToolLoopOrchestrator + private toolLoopOrchestrator!: ToolLoopOrchestrator; + // 主聊天入口及会话 CRUD 委托给 ChatService + private chatService!: ChatService; + + constructor( + private group: Group, + private sender: MessageSend, + resourceService?: ResourceService + ) { + this.skillService = new SkillService(sender, resourceService); + this.modelService = new AgentModelService(group); + this.opfsService = new AgentOPFSService(sender); + this.llmClient = new LLMClient(); + this.compactService = new CompactService(this.modelService, { + callLLM: (model, params, sendEvent, signal) => this.llmClient.callLLM(model, params, sendEvent, signal), + }); + this.toolLoopOrchestrator = new ToolLoopOrchestrator(this.toolRegistry, { + // callLLM 通过 lambda 注入,确保测试 spy 可以拦截 service.callLLM + callLLM: (model, params, sendEvent, signal) => this.callLLM(model, params, sendEvent, signal), + autoCompact: (convId, model, msgs, sendEvent, signal) => + this.compactService.autoCompact(convId, model, msgs, sendEvent, signal), + }); + this.subAgentService = new SubAgentService(this.toolRegistry, { + callLLMWithToolLoop: (params) => this.callLLMWithToolLoop(params), + }); + this.chatService = new ChatService( + this.toolRegistry, + this.modelService, + this.skillService, + this.compactService, + this.bgSessionManager, + this.subAgentService, + { + executeInPage: (code, options) => this.domService.executeScript(code, options), + executeInSandbox: (code) => { + const uuid = SKILL_SCRIPT_UUID_PREFIX + uuidv4(); + return executeSkillScript(this.sender, { + uuid, + code, + args: {}, + grants: [], + name: "execute_script", + }); + }, + }, + { + callLLM: (model, params, sendEvent, signal) => this.callLLM(model, params, sendEvent, signal), + callLLMWithToolLoop: (params) => this.callLLMWithToolLoop(params), + } + ); + } + + handleDomApi(request: DomApiRequest): Promise { + return this.domService.handleDomApi(request); + } + + init() { + // 注入 chatRepo 到 ToolRegistry 用于保存附件 + this.toolRegistry.setChatRepo(agentChatRepo); + // 初始化 MCP Service + this.mcpService = new MCPService(this.toolRegistry); + this.mcpService.init(); + // Sandbox conversation API + this.group.on("conversation", this.handleConversation.bind(this)); + // 流式聊天(UI 和 Sandbox 共用) + this.group.on("conversationChat", this.handleConversationChat.bind(this)); + // 附加到后台运行中的会话 + this.group.on("attachToConversation", this.handleAttachToConversation.bind(this)); + // 获取正在运行的会话 ID 列表 + this.group.on("getRunningConversationIds", () => this.getRunningConversationIds()); + // Skill 管理(供 Options UI 调用) + this.group.on( + "installSkill", + (params: { + skillMd: string; + scripts?: Array<{ name: string; code: string }>; + references?: Array<{ name: string; content: string }>; + }) => this.installSkill(params.skillMd, params.scripts, params.references) + ); + this.group.on("removeSkill", (name: string) => this.removeSkill(name)); + this.group.on("refreshSkill", (name: string) => this.refreshSkill(name)); + this.group.on("setSkillEnabled", (params: { name: string; enabled: boolean }) => + this.setSkillEnabled(params.name, params.enabled) + ); + 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)); + this.group.on("completeSkillInstall", (uuid: string) => this.completeSkillInstall(uuid)); + this.group.on("cancelSkillInstall", (uuid: string) => this.cancelSkillInstall(uuid)); + // Model CRUD 及摘要模型 API(委托给 AgentModelService) + this.modelService.init(); + // MCP API(供 Options UI 调用,复用已有的 handleMCPApi) + this.group.on("mcpApi", (request: MCPApiRequest) => this.mcpService.handleMCPApi(request)); + // Agent 定时任务 API + this.group.on("agentTask", this.handleAgentTask.bind(this)); + // 初始化 AgentTaskService(在 skillService 初始化后) + this.agentTaskService = new AgentTaskService( + this.sender, + agentChatRepo, + this.toolRegistry, + this.skillService, + { + getModel: (id) => this.getModel(id), + callLLMWithToolLoop: (params) => this.callLLMWithToolLoop(params), + }, + this.taskRepo, + this.taskRunRepo + ); + // 初始化定时任务调度器 + this.taskScheduler = new AgentTaskScheduler( + this.taskRepo, + this.taskRunRepo, + (task) => this.agentTaskService.executeInternalTask(task), + (task) => this.agentTaskService.emitTaskEvent(task) + ); + this.taskScheduler.init(); + // 注入 scheduler 到 AgentTaskService(解决循环依赖) + this.agentTaskService.setScheduler(this.taskScheduler); + // 搜索配置 API(供 Options UI 调用) + this.group.on("getSearchConfig", () => this.searchConfigRepo.getConfig()); + this.group.on("saveSearchConfig", (config: SearchEngineConfig) => this.searchConfigRepo.saveConfig(config)); + // 注册永久内置工具 + this.toolRegistry.registerBuiltin( + WEB_FETCH_DEFINITION, + new WebFetchExecutor(this.sender, { + summarize: (content, prompt) => this.summarizeContent(content, prompt), + }) + ); + this.toolRegistry.registerBuiltin(WEB_SEARCH_DEFINITION, new WebSearchExecutor(this.sender, this.searchConfigRepo)); + // 注册 OPFS 工作区文件工具 + // 注入 blob URL 创建函数(通过 Offscreen 的 URL.createObjectURL) + setCreateBlobUrlFn(async (data: ArrayBuffer, mimeType: string) => { + const blob = new Blob([data], { type: mimeType }); + return (await createObjectURL(this.sender, { blob, persistence: true })) as string; + }); + const opfsTools = createOPFSTools(); + for (const t of opfsTools.tools) { + this.toolRegistry.registerBuiltin(t.definition, t.executor); + } + // 注册 Tab 操作工具 + const tabTools = createTabTools({ + sender: this.sender, + summarize: (content, prompt) => this.summarizeContent(content, prompt), + }); + for (const t of tabTools.tools) { + this.toolRegistry.registerBuiltin(t.definition, t.executor); + } + // 加载已安装的 Skills + this.skillService.loadSkills(); + } + + // 获取工具注册表(供外部注册内置工具) + getToolRegistry(): ToolRegistry { + return this.toolRegistry; + } + + // ---- Skill 管理(瘦委托到 SkillService)---- + + // 安装 Skill + async installSkill( + skillMd: string, + scripts?: Array<{ name: string; code: string }>, + references?: Array<{ name: string; content: string }> + ): Promise { + return this.skillService.installSkill(skillMd, scripts, references); + } + + // 卸载 Skill + async removeSkill(name: string): Promise { + return this.skillService.removeSkill(name); + } + + // 刷新单个 Skill 缓存(从 OPFS 重新加载) + async refreshSkill(name: string): Promise { + return this.skillService.refreshSkill(name); + } + + // 启用/禁用 Skill + async setSkillEnabled(name: string, enabled: boolean): Promise { + return this.skillService.setSkillEnabled(name, enabled); + } + + // 缓存 Skill ZIP 数据,返回 uuid,供安装页面获取 + async prepareSkillInstall(zipBase64: string): Promise { + return this.skillService.prepareSkillInstall(zipBase64); + } + + // 获取缓存的 Skill ZIP 数据并解析 + async getSkillInstallData(uuid: string): Promise<{ + skillMd: string; + metadata: SkillMetadata; + prompt: string; + scripts: Array<{ name: string; code: string }>; + references: Array<{ name: string; content: string }>; + isUpdate: boolean; + }> { + return this.skillService.getSkillInstallData(uuid); + } + + // Skill 安装页面确认安装 + async completeSkillInstall(uuid: string): Promise { + return this.skillService.completeSkillInstall(uuid); + } + + // Skill 安装页面取消 + async cancelSkillInstall(uuid: string): Promise { + return this.skillService.cancelSkillInstall(uuid); + } + + // 处理 CAT.agent.skills API 请求 + async handleSkillsApi(request: SkillApiRequest): Promise { + return this.skillService.handleSkillsApi(request); + } + + // 处理 CAT.agent.opfs API 请求,委托给 AgentOPFSService + async handleOPFSApi(request: OPFSApiRequest, sender: IGetSender): Promise { + return this.opfsService.handleOPFSApi(request, sender, agentChatRepo); + } + + // 解析对话关联的 skills(测试兼容:通过 (service as any).resolveSkills 访问) + private resolveSkills(skills?: "auto" | string[]) { + return this.skillService.resolveSkills(skills); + } + + // 更新后台会话的流式状态快照(测试兼容:通过 (service as any).updateStreamingState 访问) + private updateStreamingState(rc: RunningConversation, event: ChatStreamEvent) { + this.bgSessionManager.updateStreamingState(rc, event); + } + + // 广播事件到所有 listener(测试兼容:通过 (service as any).broadcastEvent 访问) + private broadcastEvent(rc: RunningConversation, event: ChatStreamEvent) { + this.bgSessionManager.broadcastEvent(rc, event); + } + + // 获取模型配置,委托给 AgentModelService + private async getModel(modelId?: string): Promise { + return this.modelService.getModel(modelId); + } + + // 定时任务调度器 tick,由 alarm handler 调用 + async onSchedulerTick() { + await this.taskScheduler.tick(); + } + + // 处理定时任务 API 请求(委托给 AgentTaskService) + private async handleAgentTask(params: AgentTaskApiRequest) { + return this.agentTaskService.handleAgentTask(params); + } + + // 处理 conversation API 请求(非流式),供 GMApi 调用 + async handleConversationApi(params: ConversationApiRequest) { + return this.handleConversation(params); + } + + // 处理定时任务 API 请求,供 GMApi 调用 + async handleAgentTaskApi(params: AgentTaskApiRequest) { + return this.agentTaskService.handleAgentTask(params); + } + + // 处理 CAT.agent.model API 请求,委托给 AgentModelService + async handleModelApi( + request: ModelApiRequest + ): Promise { + return this.modelService.handleModelApi(request); + } + + // 处理流式 conversation chat,供 GMApi 调用 + async handleConversationChatFromGmApi( + params: { + conversationId: string; + message: MessageContent; + tools?: ToolDefinition[]; + maxIterations?: number; + scriptUuid: string; + // ephemeral 会话专用字段 + ephemeral?: boolean; + messages?: ChatRequest["messages"]; + system?: string; + modelId?: string; + cache?: boolean; + background?: boolean; + }, + sender: IGetSender + ) { + return this.handleConversationChat(params, sender); + } + + // 附加到后台运行会话,供 GMApi 调用 + async handleAttachToConversationFromGmApi(params: { conversationId: string }, sender: IGetSender) { + return this.handleAttachToConversation(params, sender); + } + + // 获取正在运行的会话 ID 列表 + getRunningConversationIds(): string[] { + return this.bgSessionManager.listIds(); + } + + // 附加到后台运行中的会话(委托给 BackgroundSessionManager) + private async handleAttachToConversation(params: { conversationId: string }, sender: IGetSender) { + return this.bgSessionManager.handleAttach(params, sender); + } + + // 处理 Sandbox conversation API 请求(非流式,委托给 ChatService) + private async handleConversation(params: ConversationApiRequest) { + return this.chatService.handleConversation(params); + } + + // 统一的 tool calling 循环,UI 和脚本共用(委托给 ToolLoopOrchestrator) + private callLLMWithToolLoop(params: Parameters[0]): Promise { + return this.toolLoopOrchestrator.callLLMWithToolLoop(params); + } + + // 主聊天入口(委托给 ChatService) + private async handleConversationChat( + params: Parameters[0], + sender: IGetSender + ) { + return this.chatService.handleConversationChat(params, sender); + } + + // 对内容做摘要/提取(供 tab 工具使用) + // 优先使用摘要模型,fallback 到默认模型 + private async summarizeContent(content: string, prompt: string): Promise { + return this.compactService.summarizeContent(content, prompt); + } + + // 调用 LLM 并收集完整响应(委托给 LLMClient) + private async callLLM( + model: AgentModelConfig, + params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ) { + return this.llmClient.callLLM(model, params, sendEvent, signal); + } +} diff --git a/src/app/service/agent/service_worker/autocompact.test.ts b/src/app/service/agent/service_worker/autocompact.test.ts new file mode 100644 index 000000000..9544fac08 --- /dev/null +++ b/src/app/service/agent/service_worker/autocompact.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createTestService, makeSSEResponse } from "./test-helpers"; + +// ---- Compact 功能测试 ---- + +describe("Compact 功能", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + function makeTextResponseWithTokens(text: string, promptTokens = 10): Response { + return makeSSEResponse([ + `data: {"choices":[{"delta":{"content":${JSON.stringify(text)}}}]}\n\n`, + `data: {"usage":{"prompt_tokens":${promptTokens},"completion_tokens":5}}\n\n`, + ]); + } + + function createMockSender() { + const sentMessages: any[] = []; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + return { sender, sentMessages }; + } + + const BASE_CONV = { + id: "conv-1", + title: "Test", + modelId: "test-openai", + createtime: Date.now(), + updatetime: Date.now(), + }; + + it("手动 compact:LLM 返回带 的摘要", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([ + { id: "m1", conversationId: "conv-1", role: "user", content: "Hello", createtime: 1 }, + { id: "m2", conversationId: "conv-1", role: "assistant", content: "Hi there!", createtime: 2 }, + ]); + + const summaryText = + "Some analysis\nUser said hello. Assistant greeted back."; + fetchSpy.mockResolvedValueOnce(makeTextResponseWithTokens(summaryText)); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "", compact: true }, sender); + + // 验证 saveMessages 被调用,替换为摘要消息 + expect(mockRepo.saveMessages).toHaveBeenCalledTimes(1); + const savedMessages = mockRepo.saveMessages.mock.calls[0][1]; + expect(savedMessages).toHaveLength(1); + expect(savedMessages[0].content).toContain("[Conversation Summary]"); + expect(savedMessages[0].content).toContain("User said hello. Assistant greeted back."); + expect(savedMessages[0].role).toBe("user"); + + // 验证发送了 compact_done 和 done 事件 + const events = sentMessages.map((m) => m.data); + const compactDone = events.find((e: any) => e.type === "compact_done"); + expect(compactDone).toBeDefined(); + expect(compactDone.summary).toBe("User said hello. Assistant greeted back."); + expect(compactDone.originalCount).toBe(2); + expect(events.some((e: any) => e.type === "done")).toBe(true); + }); + + it("手动 compact:带自定义指令", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([ + { id: "m1", conversationId: "conv-1", role: "user", content: "Test", createtime: 1 }, + ]); + + fetchSpy.mockResolvedValueOnce(makeTextResponseWithTokens("Custom summary")); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "", compact: true, compactInstruction: "只保留代码" }, + sender + ); + + // 验证 LLM 请求中包含自定义指令 + const fetchCall = fetchSpy.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body as string); + const lastUserMsg = body.messages[body.messages.length - 1]; + expect(lastUserMsg.content).toContain("只保留代码"); + }); + + it("手动 compact:空消息时返回错误", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "", compact: true }, sender); + + // 不应调用 saveMessages + expect(mockRepo.saveMessages).not.toHaveBeenCalled(); + // 不应调用 fetch + expect(fetchSpy).not.toHaveBeenCalled(); + // 应发送 error 事件 + const events = sentMessages.map((m) => m.data); + const errorEvent = events.find((e: any) => e.type === "error"); + expect(errorEvent).toBeDefined(); + expect(errorEvent.message).toBe("No messages to compact"); + }); + + it("手动 compact:会话不存在时返回错误", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([]); + + await (service as any).handleConversationChat( + { conversationId: "nonexistent", message: "", compact: true }, + sender + ); + + const events = sentMessages.map((m) => m.data); + expect(events.some((e: any) => e.type === "error" && e.message === "Conversation not found")).toBe(true); + }); + + it("自动 compact:usage 超过 80% 阈值时触发", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // gpt-4o contextWindow = 128000 + // 第一次 LLM 调用:返回文本但 inputTokens 超过 80% (110000/128000 ≈ 86%) + fetchSpy.mockResolvedValueOnce( + makeSSEResponse([ + `data: {"choices":[{"delta":{"content":"Some response"}}]}\n\n`, + `data: {"usage":{"prompt_tokens":110000,"completion_tokens":100}}\n\n`, + ]) + ); + + // 第二次 LLM 调用:autoCompact 的摘要请求 + fetchSpy.mockResolvedValueOnce(makeTextResponseWithTokens("Auto compacted summary")); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "test message" }, sender); + + // 验证 autoCompact 被触发:saveMessages 应被调用(用摘要替换历史) + expect(mockRepo.saveMessages).toHaveBeenCalled(); + const savedMessages = mockRepo.saveMessages.mock.calls[0][1]; + expect(savedMessages).toHaveLength(1); + expect(savedMessages[0].content).toContain("[Conversation Summary]"); + + // 验证发送了 compact_done 事件 + const events = sentMessages.map((m) => m.data); + const compactDone = events.find((e: any) => e.type === "compact_done"); + expect(compactDone).toBeDefined(); + expect(compactDone.originalCount).toBe(-1); // 自动 compact 标记 + }); + + it("自动 compact:usage 低于 80% 阈值不触发", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // inputTokens = 50000, contextWindow(gpt-4o) = 128000, 39% < 80% + fetchSpy.mockResolvedValueOnce( + makeSSEResponse([ + `data: {"choices":[{"delta":{"content":"OK"}}]}\n\n`, + `data: {"usage":{"prompt_tokens":50000,"completion_tokens":5}}\n\n`, + ]) + ); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "test" }, sender); + + // saveMessages 不应被调用(没有 compact) + expect(mockRepo.saveMessages).not.toHaveBeenCalled(); + // fetch 只调用 1 次(正常 LLM 调用) + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/service/agent/service_worker/background.test.ts b/src/app/service/agent/service_worker/background.test.ts new file mode 100644 index 000000000..83a409251 --- /dev/null +++ b/src/app/service/agent/service_worker/background.test.ts @@ -0,0 +1,601 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createTestService, makeTextResponse, createRunningConversation } from "./test-helpers"; + +// ---- updateStreamingState 快照状态管理 ---- + +describe("updateStreamingState 快照状态管理", () => { + it("content_delta 累积文本内容", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + (service as any).updateStreamingState(rc, { type: "content_delta", delta: "Hello" }); + (service as any).updateStreamingState(rc, { type: "content_delta", delta: " World" }); + + expect(rc.streamingState.content).toBe("Hello World"); + }); + + it("thinking_delta 累积思考内容", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + (service as any).updateStreamingState(rc, { type: "thinking_delta", delta: "Let me " }); + (service as any).updateStreamingState(rc, { type: "thinking_delta", delta: "think..." }); + + expect(rc.streamingState.thinking).toBe("Let me think..."); + }); + + it("tool_call_start/delta/complete 完整追踪工具调用", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + // 开始 + (service as any).updateStreamingState(rc, { + type: "tool_call_start", + toolCall: { id: "tc1", name: "web_search", arguments: "" }, + }); + expect(rc.streamingState.toolCalls).toHaveLength(1); + expect(rc.streamingState.toolCalls[0].status).toBe("running"); + + // 参数增量 + (service as any).updateStreamingState(rc, { type: "tool_call_delta", id: "tc1", delta: '{"q":' }); + (service as any).updateStreamingState(rc, { type: "tool_call_delta", id: "tc1", delta: '"test"}' }); + expect(rc.streamingState.toolCalls[0].arguments).toBe('{"q":"test"}'); + + // 完成 + (service as any).updateStreamingState(rc, { + type: "tool_call_complete", + id: "tc1", + result: "search results", + attachments: [{ id: "att1", type: "file", name: "result.txt", mimeType: "text/plain" }], + }); + expect(rc.streamingState.toolCalls[0].status).toBe("completed"); + expect(rc.streamingState.toolCalls[0].result).toBe("search results"); + expect(rc.streamingState.toolCalls[0].attachments).toHaveLength(1); + }); + + it("new_message 重置流式状态", () => { + const { service } = createTestService(); + const rc = createRunningConversation({ + streamingState: { + content: "old content", + thinking: "old thinking", + toolCalls: [{ id: "tc1", name: "t", arguments: "{}", status: "completed" }], + }, + }); + + (service as any).updateStreamingState(rc, { type: "new_message" }); + + expect(rc.streamingState.content).toBe(""); + expect(rc.streamingState.thinking).toBe(""); + expect(rc.streamingState.toolCalls).toEqual([]); + }); + + it("ask_user 设置 pendingAskUser 状态", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + (service as any).updateStreamingState(rc, { + type: "ask_user", + id: "ask-1", + question: "选择颜色", + options: ["红", "蓝"], + multiple: false, + }); + + expect(rc.pendingAskUser).toEqual({ + id: "ask-1", + question: "选择颜色", + options: ["红", "蓝"], + multiple: false, + }); + }); + + it("task_update 更新任务列表", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + const tasks = [ + { id: "t1", subject: "步骤1", status: "completed" as const }, + { id: "t2", subject: "步骤2", status: "in_progress" as const, description: "进行中" }, + ]; + (service as any).updateStreamingState(rc, { type: "task_update", tasks }); + + expect(rc.tasks).toEqual(tasks); + }); + + it("done 设置状态并清除 pendingAskUser", () => { + const { service } = createTestService(); + const rc = createRunningConversation({ + pendingAskUser: { id: "ask-1", question: "test" }, + }); + + (service as any).updateStreamingState(rc, { type: "done", usage: { inputTokens: 10, outputTokens: 5 } }); + + expect(rc.status).toBe("done"); + expect(rc.pendingAskUser).toBeUndefined(); + }); + + it("error 设置状态并清除 pendingAskUser", () => { + const { service } = createTestService(); + const rc = createRunningConversation({ + pendingAskUser: { id: "ask-1", question: "test" }, + }); + + (service as any).updateStreamingState(rc, { type: "error", message: "API error" }); + + expect(rc.status).toBe("error"); + expect(rc.pendingAskUser).toBeUndefined(); + }); + + it("tool_call_complete 对不存在的 id 不报错", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + // 不应抛异常 + (service as any).updateStreamingState(rc, { + type: "tool_call_complete", + id: "nonexistent", + result: "result", + }); + expect(rc.streamingState.toolCalls).toHaveLength(0); + }); + + it("tool_call_delta 无工具时不报错", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + // 空 toolCalls 列表时不应报错 + (service as any).updateStreamingState(rc, { type: "tool_call_delta", id: "tc1", delta: "data" }); + expect(rc.streamingState.toolCalls).toHaveLength(0); + }); +}); + +// ---- broadcastEvent 广播与容错 ---- + +describe("broadcastEvent 广播与容错", () => { + it("广播事件到所有 listener", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + const received1: any[] = []; + const received2: any[] = []; + rc.listeners.add({ sendEvent: (e: any) => received1.push(e) }); + rc.listeners.add({ sendEvent: (e: any) => received2.push(e) }); + + const event = { type: "content_delta" as const, delta: "hi" }; + (service as any).broadcastEvent(rc, event); + + expect(received1).toEqual([event]); + expect(received2).toEqual([event]); + }); + + it("某个 listener 抛异常不影响其他 listener 接收", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + const received: any[] = []; + rc.listeners.add({ + sendEvent: () => { + throw new Error("listener disconnected"); + }, + }); + rc.listeners.add({ sendEvent: (e: any) => received.push(e) }); + + const event = { type: "content_delta" as const, delta: "hi" }; + // 不应抛异常 + (service as any).broadcastEvent(rc, event); + + // 第二个 listener 应正常收到 + expect(received).toEqual([event]); + }); + + it("无 listener 时不报错", () => { + const { service } = createTestService(); + const rc = createRunningConversation(); + + // 空 listeners,不应报错 + (service as any).broadcastEvent(rc, { type: "done" as const }); + }); +}); + +// ---- handleAttachToConversation 重连逻辑 ---- + +describe("handleAttachToConversation 重连逻辑", () => { + function createMockSender() { + const sentMessages: any[] = []; + let messageHandler: ((msg: any) => void) | null = null; + let disconnectHandler: (() => void) | null = null; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn((handler: any) => { + messageHandler = handler; + }), + onDisconnect: vi.fn((handler: any) => { + disconnectHandler = handler; + }), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + return { + sender, + sentMessages, + simulateMessage: (msg: any) => messageHandler?.(msg), + simulateDisconnect: () => disconnectHandler?.(), + }; + } + + it("会话不在运行中时返回 sync { status: 'done' }", async () => { + const { service } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + await (service as any).handleAttachToConversation({ conversationId: "nonexistent" }, sender); + + const syncEvent = sentMessages.find((m: any) => m.action === "event" && m.data.type === "sync"); + expect(syncEvent).toBeDefined(); + expect(syncEvent.data.status).toBe("done"); + expect(syncEvent.data.tasks).toEqual([]); + expect(syncEvent.data.streamingMessage).toBeUndefined(); + expect(syncEvent.data.pendingAskUser).toBeUndefined(); + }); + + it("运行中的会话发送完整 sync 快照(含 streamingMessage / pendingAskUser / tasks)", async () => { + const { service } = createTestService(); + const rc = createRunningConversation({ + streamingState: { + content: "正在分析...", + thinking: "考虑方案", + toolCalls: [{ id: "tc1", name: "search", arguments: "{}", status: "completed", result: "ok" }], + }, + pendingAskUser: { id: "ask-1", question: "请选择", options: ["A", "B"] }, + tasks: [{ id: "t1", subject: "第一步", status: "in_progress" }], + status: "running", + }); + (service as any).runningConversations.set("conv-sync", rc); + + const { sender, sentMessages } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-sync" }, sender); + + const syncEvent = sentMessages.find((m: any) => m.action === "event" && m.data.type === "sync"); + expect(syncEvent).toBeDefined(); + expect(syncEvent.data.status).toBe("running"); + expect(syncEvent.data.streamingMessage).toEqual({ + content: "正在分析...", + thinking: "考虑方案", + toolCalls: [{ id: "tc1", name: "search", arguments: "{}", status: "completed", result: "ok" }], + }); + expect(syncEvent.data.pendingAskUser).toEqual({ id: "ask-1", question: "请选择", options: ["A", "B"] }); + expect(syncEvent.data.tasks).toEqual([{ id: "t1", subject: "第一步", status: "in_progress" }]); + + // 清理 + (service as any).runningConversations.delete("conv-sync"); + }); + + it("已完成的会话不添加 listener", async () => { + const { service } = createTestService(); + const rc = createRunningConversation({ status: "done" }); + (service as any).runningConversations.set("conv-done", rc); + + const { sender } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-done" }, sender); + + // listener 不应被添加 + expect(rc.listeners.size).toBe(0); + + (service as any).runningConversations.delete("conv-done"); + }); + + it("运行中的会话添加 listener,断开时移除", async () => { + const { service } = createTestService(); + const rc = createRunningConversation({ status: "running" }); + (service as any).runningConversations.set("conv-run", rc); + + const { sender, simulateDisconnect } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-run" }, sender); + + expect(rc.listeners.size).toBe(1); + + // 断开后移除 + simulateDisconnect(); + expect(rc.listeners.size).toBe(0); + + (service as any).runningConversations.delete("conv-run"); + }); + + it("通过 attach 发送 askUserResponse 能正确 resolve", async () => { + const { service } = createTestService(); + const rc = createRunningConversation({ status: "running" }); + + // 注册一个 askResolver + let resolvedAnswer: string | undefined; + rc.askResolvers.set("ask-1", (answer: string) => { + resolvedAnswer = answer; + }); + rc.pendingAskUser = { id: "ask-1", question: "选择" }; + (service as any).runningConversations.set("conv-ask", rc); + + const { sender, simulateMessage } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-ask" }, sender); + + // 模拟 UI 回复 ask_user + simulateMessage({ action: "askUserResponse", data: { id: "ask-1", answer: "红色" } }); + + expect(resolvedAnswer).toBe("红色"); + expect(rc.pendingAskUser).toBeUndefined(); + expect(rc.askResolvers.has("ask-1")).toBe(false); + + (service as any).runningConversations.delete("conv-ask"); + }); + + it("多个 listener 回复同一 ask_user 只有第一个生效", async () => { + const { service } = createTestService(); + const rc = createRunningConversation({ status: "running" }); + + let resolveCount = 0; + let lastAnswer = ""; + rc.askResolvers.set("ask-1", (answer: string) => { + resolveCount++; + lastAnswer = answer; + }); + rc.pendingAskUser = { id: "ask-1", question: "选择" }; + (service as any).runningConversations.set("conv-multi", rc); + + const { sender: sender1, simulateMessage: sim1 } = createMockSender(); + const { sender: sender2, simulateMessage: sim2 } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-multi" }, sender1); + await (service as any).handleAttachToConversation({ conversationId: "conv-multi" }, sender2); + + // 两个 listener 同时回复 + sim1({ action: "askUserResponse", data: { id: "ask-1", answer: "第一个" } }); + sim2({ action: "askUserResponse", data: { id: "ask-1", answer: "第二个" } }); + + expect(resolveCount).toBe(1); + expect(lastAnswer).toBe("第一个"); + + (service as any).runningConversations.delete("conv-multi"); + }); + + it("通过 attach 发送 stop 能中止会话", async () => { + const { service } = createTestService(); + const rc = createRunningConversation({ status: "running" }); + (service as any).runningConversations.set("conv-stop", rc); + + const { sender, simulateMessage } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-stop" }, sender); + + simulateMessage({ action: "stop" }); + + expect(rc.abortController.signal.aborted).toBe(true); + + (service as any).runningConversations.delete("conv-stop"); + }); + + it("空 streamingState 的 sync 不包含 streamingMessage 字段", async () => { + const { service } = createTestService(); + const rc = createRunningConversation({ status: "running" }); + (service as any).runningConversations.set("conv-empty", rc); + + const { sender, sentMessages } = createMockSender(); + await (service as any).handleAttachToConversation({ conversationId: "conv-empty" }, sender); + + const syncEvent = sentMessages.find((m: any) => m.action === "event" && m.data.type === "sync"); + expect(syncEvent.data.streamingMessage).toBeUndefined(); + + (service as any).runningConversations.delete("conv-empty"); + }); +}); + +// ---- 后台运行会话 集成测试 ---- + +describe("后台运行会话 集成测试", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + afterEach(() => { + fetchSpy.mockRestore(); + }); + + function createMockSender() { + const sentMessages: any[] = []; + let messageHandler: ((msg: any) => void) | null = null; + let disconnectHandler: (() => void) | null = null; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn((handler: any) => { + messageHandler = handler; + }), + onDisconnect: vi.fn((handler: any) => { + disconnectHandler = handler; + }), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + return { + sender, + sentMessages, + simulateMessage: (msg: any) => messageHandler?.(msg), + simulateDisconnect: () => disconnectHandler?.(), + }; + } + + function setupConversation(mockRepo: any) { + const conv = { + id: "conv-bg", + title: "BG Chat", + modelId: "test-openai", + createtime: Date.now(), + updatetime: Date.now(), + }; + mockRepo.listConversations.mockResolvedValue([conv]); + return conv; + } + + it("后台模式:listener 断开不中止会话,消息仍然持久化", async () => { + const { service, mockRepo } = createTestService(); + setupConversation(mockRepo); + + const { sender, simulateDisconnect } = createMockSender(); + + const encoder = new TextEncoder(); + let readCalled = 0; + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + readCalled++; + if (readCalled === 1) { + return { + done: false, + value: encoder.encode(`data: {"choices":[{"delta":{"content":"hello"}}]}\n\n`), + }; + } + if (readCalled === 2) { + // 在第二次 read 前断开 listener + simulateDisconnect(); + await new Promise((r) => setTimeout(r, 10)); + return { + done: false, + value: encoder.encode(`data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`), + }; + } + return { done: true, value: undefined }; + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + }), + }, + text: async () => "", + } as unknown as Response); + + await (service as any).handleConversationChat( + { conversationId: "conv-bg", message: "test", background: true }, + sender + ); + + // 会话应正常完成(消息已持久化) + expect(mockRepo.appendMessage).toHaveBeenCalled(); + + // runningConversations 应包含该会话(延迟清理中) + const rc = (service as any).runningConversations.get("conv-bg"); + expect(rc).toBeDefined(); + expect(rc.status).toBe("done"); + }); + + it("后台模式:同会话并发请求被拒绝", async () => { + const { service, mockRepo } = createTestService(); + setupConversation(mockRepo); + + let resolveRead: () => void; + const readPromise = new Promise((r) => { + resolveRead = r; + }); + + fetchSpy.mockResolvedValue({ + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + return readPromise.then(() => ({ done: true, value: undefined })); + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + }), + }, + text: async () => "", + } as unknown as Response); + + const { sender: sender1 } = createMockSender(); + const { sender: sender2, sentMessages: msgs2 } = createMockSender(); + + const promise1 = (service as any).handleConversationChat( + { conversationId: "conv-bg", message: "test", background: true }, + sender1 + ); + + await new Promise((r) => setTimeout(r, 10)); + + // 第二个请求应被拒绝 + await (service as any).handleConversationChat( + { conversationId: "conv-bg", message: "test2", background: true }, + sender2 + ); + + const errorEvent = msgs2.find((m: any) => m.action === "event" && m.data.type === "error"); + expect(errorEvent).toBeDefined(); + expect(errorEvent.data.message).toContain("正在运行中"); + + resolveRead!(); + await promise1; + }); + + it("非后台模式:保持原有行为(不注册到 runningConversations)", async () => { + const { service, mockRepo } = createTestService(); + setupConversation(mockRepo); + + const { sender, sentMessages } = createMockSender(); + + fetchSpy.mockResolvedValueOnce(makeTextResponse("你好")); + + await (service as any).handleConversationChat({ conversationId: "conv-bg", message: "test" }, sender); + + expect((service as any).runningConversations.has("conv-bg")).toBe(false); + + const events = sentMessages.filter((m: any) => m.action === "event").map((m: any) => m.data); + expect(events.some((e: any) => e.type === "done")).toBe(true); + }); + + it("后台模式:stop 指令中止会话后不抛未捕获异常", async () => { + const { service, mockRepo } = createTestService(); + setupConversation(mockRepo); + + const { sender, simulateMessage } = createMockSender(); + + fetchSpy.mockImplementation(async (_url: any, init: any) => { + if (init?.signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + await new Promise((r) => setTimeout(r, 50)); + if (init?.signal?.aborted) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + return makeTextResponse("hello"); + }); + + const chatPromise = (service as any).handleConversationChat( + { conversationId: "conv-bg", message: "test", background: true }, + sender + ); + + await new Promise((r) => setTimeout(r, 10)); + simulateMessage({ action: "stop" }); + + await chatPromise; + // 不应抛异常 + }); + + it("getRunningConversationIds 返回正确的 ID 列表", () => { + const { service } = createTestService(); + + expect(service.getRunningConversationIds()).toEqual([]); + + (service as any).runningConversations.set("conv-1", { status: "running" }); + (service as any).runningConversations.set("conv-2", { status: "done" }); + + const ids = service.getRunningConversationIds(); + expect(ids).toHaveLength(2); + expect(ids).toContain("conv-1"); + expect(ids).toContain("conv-2"); + + (service as any).runningConversations.clear(); + }); +}); diff --git a/src/app/service/agent/service_worker/background_session_manager.ts b/src/app/service/agent/service_worker/background_session_manager.ts new file mode 100644 index 000000000..ed590a4e4 --- /dev/null +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -0,0 +1,183 @@ +import type { IGetSender } from "@Packages/message/server"; +import { GetSenderType } from "@Packages/message/server"; +import type { ChatStreamEvent, ToolCall } from "@App/app/service/agent/core/types"; + +// 后台运行会话的 listener 条目 +export type ListenerEntry = { + sendEvent: (event: ChatStreamEvent) => void; +}; + +// 后台运行会话状态 +export type RunningConversation = { + conversationId: string; + abortController: AbortController; + listeners: Set; + streamingState: { content: string; thinking: string; toolCalls: ToolCall[] }; + pendingAskUser?: { id: string; question: string; options?: string[]; multiple?: boolean }; + askResolvers: Map void>; + tasks: Array<{ id: string; subject: string; status: "pending" | "in_progress" | "completed"; description?: string }>; + status: "running" | "done" | "error"; +}; + +// 后台会话注册表:管理流式状态快照、listener 广播、UI 附加逻辑 +export class BackgroundSessionManager { + private runningConversations = new Map(); + + has(conversationId: string): boolean { + return this.runningConversations.has(conversationId); + } + + get(conversationId: string): RunningConversation | undefined { + return this.runningConversations.get(conversationId); + } + + set(conversationId: string, rc: RunningConversation): void { + this.runningConversations.set(conversationId, rc); + } + + delete(conversationId: string): void { + this.runningConversations.delete(conversationId); + } + + listIds(): string[] { + return Array.from(this.runningConversations.keys()); + } + + // 更新后台会话的流式状态快照 + updateStreamingState(rc: RunningConversation, event: ChatStreamEvent) { + switch (event.type) { + case "content_delta": + rc.streamingState.content += event.delta; + break; + case "thinking_delta": + rc.streamingState.thinking += event.delta; + break; + case "tool_call_start": + rc.streamingState.toolCalls.push({ ...event.toolCall, status: "running" }); + break; + case "tool_call_delta": + if (rc.streamingState.toolCalls.length > 0) { + const last = rc.streamingState.toolCalls[rc.streamingState.toolCalls.length - 1]; + last.arguments += event.delta; + } + break; + case "tool_call_complete": { + const tc = rc.streamingState.toolCalls.find((t) => t.id === event.id); + if (tc) { + tc.status = "completed"; + tc.result = event.result; + tc.attachments = event.attachments; + } + break; + } + case "new_message": + // 新一轮 LLM 调用,重置流式状态 + rc.streamingState = { content: "", thinking: "", toolCalls: [] }; + break; + case "ask_user": + rc.pendingAskUser = { + id: event.id, + question: event.question, + options: event.options, + multiple: event.multiple, + }; + break; + case "task_update": + rc.tasks = event.tasks; + break; + case "done": + rc.status = "done"; + rc.pendingAskUser = undefined; + break; + case "error": + rc.status = "error"; + rc.pendingAskUser = undefined; + break; + } + } + + // 广播事件到所有 listener + broadcastEvent(rc: RunningConversation, event: ChatStreamEvent) { + for (const listener of rc.listeners) { + try { + listener.sendEvent(event); + } catch { + // listener 断开,忽略 + } + } + } + + // 附加 UI 连接到后台运行中的会话(同步快照 + listener + askUser resolver + stop) + async handleAttach(params: { conversationId: string }, sender: IGetSender): Promise { + if (!sender.isType(GetSenderType.CONNECT)) { + throw new Error("attachToConversation requires connect mode"); + } + const msgConn = sender.getConnect()!; + + const rc = this.runningConversations.get(params.conversationId); + + const sendEvent = (event: ChatStreamEvent) => { + msgConn.sendMessage({ action: "event", data: event }); + }; + + if (!rc) { + // 会话不在运行中 + sendEvent({ type: "sync", tasks: [], status: "done" }); + return; + } + + // 发送 sync 快照 + const syncEvent: ChatStreamEvent = { + type: "sync", + streamingMessage: + rc.streamingState.content || rc.streamingState.thinking || rc.streamingState.toolCalls.length > 0 + ? { + content: rc.streamingState.content, + thinking: rc.streamingState.thinking || undefined, + toolCalls: rc.streamingState.toolCalls, + } + : undefined, + pendingAskUser: rc.pendingAskUser, + tasks: rc.tasks, + status: rc.status, + }; + sendEvent(syncEvent); + + // 已完成则不需要添加 listener + if (rc.status !== "running") { + return; + } + + // 添加 listener + const listener: ListenerEntry = { sendEvent }; + rc.listeners.add(listener); + + // 处理来自 UI 的消息 + msgConn.onMessage((msg: any) => { + if (msg.action === "askUserResponse" && msg.data) { + const resolver = rc.askResolvers.get(msg.data.id); + if (resolver) { + rc.askResolvers.delete(msg.data.id); + rc.pendingAskUser = undefined; + resolver(msg.data.answer); + } + } + if (msg.action === "stop") { + rc.abortController.abort(); + } + }); + + msgConn.onDisconnect(() => { + rc.listeners.delete(listener); + }); + } + + // 延迟清理后台运行会话注册表(给迟到的重连者 30s 窗口) + cleanupIfDone(conversationId: string) { + const rc = this.runningConversations.get(conversationId); + if (!rc) return; + setTimeout(() => { + this.runningConversations.delete(conversationId); + }, 30_000); + } +} diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts new file mode 100644 index 000000000..0eb210372 --- /dev/null +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -0,0 +1,523 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createTestService, makeSkillRecord, makeSkillScriptRecord, makeTextResponse } from "./test-helpers"; + +// ---- handleConversationChat skipSaveUserMessage(重新生成 bug 修复验证)---- + +describe("handleConversationChat skipSaveUserMessage", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + function createMockSender() { + const sentMessages: any[] = []; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const sender = { + isType: (type: any) => type === 1, // GetSenderType.CONNECT + getConnect: () => mockConn, + }; + return { sender, sentMessages }; + } + + // 已存在于 storage 中的用户消息(模拟重新生成场景) + const EXISTING_USER_MSG = { + id: "existing-u1", + conversationId: "conv-1", + role: "user" as const, + content: "你好", + createtime: 1000, + }; + + const BASE_CONV = { + id: "conv-1", + title: "Test", + modelId: "test-openai", + createtime: Date.now(), + updatetime: Date.now(), + }; + + it("【默认行为】不传 skipSaveUserMessage:用户消息应被保存到 storage", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); // 空历史 + fetchSpy.mockResolvedValueOnce(makeTextResponse("你好!")); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "你好", modelId: "test-openai" }, + sender + ); + + const appendCalls: any[][] = mockRepo.appendMessage.mock.calls; + const userCall = appendCalls.find((c) => c[0].role === "user"); + expect(userCall).toBeDefined(); + expect(userCall![0].content).toBe("你好"); + }); + + it("【bug 回归】skipSaveUserMessage=true:用户消息不应再次保存到 storage", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + // storage 中已有用户消息(重新生成场景) + mockRepo.getMessages.mockResolvedValue([EXISTING_USER_MSG]); + fetchSpy.mockResolvedValueOnce(makeTextResponse("你好!")); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "你好", modelId: "test-openai", skipSaveUserMessage: true }, + sender + ); + + const appendCalls: any[][] = mockRepo.appendMessage.mock.calls; + // user 角色消息不应被再次保存 + const userCall = appendCalls.find((c) => c[0].role === "user"); + expect(userCall).toBeUndefined(); + + // assistant 回复仍应被保存 + const assistantCall = appendCalls.find((c) => c[0].role === "assistant"); + expect(assistantCall).toBeDefined(); + expect(assistantCall![0].content).toBe("你好!"); + }); + + it("【bug 回归】skipSaveUserMessage=true:LLM 请求中用户消息不应出现两次", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + // storage 中已有用户消息 + mockRepo.getMessages.mockResolvedValue([EXISTING_USER_MSG]); + fetchSpy.mockResolvedValueOnce(makeTextResponse("你好!")); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "你好", modelId: "test-openai", skipSaveUserMessage: true }, + sender + ); + + // 检查发往 LLM 的请求 body + expect(fetchSpy).toHaveBeenCalledTimes(1); + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1].body); + const userMessages = requestBody.messages.filter((m: any) => m.role === "user"); + + // 用户消息只应出现一次(来自 existingMessages,不应被重复追加) + expect(userMessages).toHaveLength(1); + expect(userMessages[0].content).toBe("你好"); + }); + + it("skipSaveUserMessage=false(默认):LLM 收到 user message(来自 params.message 追加)", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); // 空历史 + fetchSpy.mockResolvedValueOnce(makeTextResponse("你好!")); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "你好", modelId: "test-openai" }, + sender + ); + + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1].body); + const userMessages = requestBody.messages.filter((m: any) => m.role === "user"); + + // 历史为空时,用户消息应来自 params.message 追加,只出现一次 + expect(userMessages).toHaveLength(1); + expect(userMessages[0].content).toBe("你好"); + }); + + it("skipSaveUserMessage=true:对话标题不应被更新(用户消息已在历史中)", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + const conv = { ...BASE_CONV, title: "New Chat" }; + mockRepo.listConversations.mockResolvedValue([conv]); + // existingMessages 非空 → 标题更新条件(length === 0)不满足 + mockRepo.getMessages.mockResolvedValue([EXISTING_USER_MSG]); + fetchSpy.mockResolvedValueOnce(makeTextResponse("你好!")); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "你好", modelId: "test-openai", skipSaveUserMessage: true }, + sender + ); + + // saveConversation 不应以更新标题为目的被调用(title 仍应为 "New Chat") + const saveConvCalls: any[][] = mockRepo.saveConversation.mock.calls; + const titleUpdated = saveConvCalls.some((c) => c[0].title !== "New Chat"); + expect(titleUpdated).toBe(false); + }); + + it("多轮对话中 skipSaveUserMessage=true:历史消息完整传入 LLM", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + // 两轮历史 + 第三条用户消息待重新生成 + mockRepo.getMessages.mockResolvedValue([ + { id: "u1", conversationId: "conv-1", role: "user", content: "第一条", createtime: 1000 }, + { id: "a1", conversationId: "conv-1", role: "assistant", content: "回复一", createtime: 1001 }, + { id: "u2", conversationId: "conv-1", role: "user", content: "第二条", createtime: 1002 }, + ]); + fetchSpy.mockResolvedValueOnce(makeTextResponse("回复二")); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "第二条", modelId: "test-openai", skipSaveUserMessage: true }, + sender + ); + + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1].body); + // 过滤 system 消息 + const nonSystem = requestBody.messages.filter((m: any) => m.role !== "system"); + + // 应有 user("第一条"), assistant("回复一"), user("第二条") — 共 3 条,无重复 + expect(nonSystem).toHaveLength(3); + expect(nonSystem[0]).toMatchObject({ role: "user", content: "第一条" }); + expect(nonSystem[1]).toMatchObject({ role: "assistant", content: "回复一" }); + expect(nonSystem[2]).toMatchObject({ role: "user", content: "第二条" }); + }); +}); + +// ---- handleConversationChat 场景补充 ---- + +describe("handleConversationChat 场景补充", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + function createMockSender() { + const sentMessages: any[] = []; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + return { sender, sentMessages }; + } + + it("对话标题自动更新:第一条消息时 title 从 New Chat 变成消息截断", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + const conv = { + id: "conv-1", + title: "New Chat", + modelId: "test-openai", + createtime: Date.now(), + updatetime: Date.now(), + }; + mockRepo.listConversations.mockResolvedValue([conv]); + mockRepo.getMessages.mockResolvedValue([]); // 空历史 → 第一条消息 + fetchSpy.mockResolvedValueOnce(makeTextResponse("ok")); + + // 使用超过 30 个字符的消息(中文和英文混合确保超过 30 字符) + const longMessage = "This is a very long message that is used for testing title truncation behavior"; + await (service as any).handleConversationChat({ conversationId: "conv-1", message: longMessage }, sender); + + // saveConversation 应被调用,标题为截断后的消息 + 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) + "..."); + }); + + it("ephemeral 模式:不走 repo 持久化", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + fetchSpy.mockResolvedValueOnce(makeTextResponse("ephemeral reply")); + + await (service as any).handleConversationChat( + { + conversationId: "eph-1", + message: "hi", + ephemeral: true, + messages: [{ role: "user", content: "hi" }], + system: "You are a helper.", + }, + sender + ); + + // ephemeral 模式不应查询 conversation + expect(mockRepo.listConversations).not.toHaveBeenCalled(); + // 不应持久化消息 + expect(mockRepo.appendMessage).not.toHaveBeenCalled(); + + // 但应收到 done 事件 + const events = sentMessages.map((m) => m.data); + expect(events.some((e: any) => e.type === "done")).toBe(true); + }); + + it("modelId 覆盖:传入新 modelId 时更新 conversation", async () => { + const { service, mockRepo } = createTestService(); + const { sender } = createMockSender(); + + // 添加第二个 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", + }); + return Promise.resolve(undefined); + }); + + const conv = { + id: "conv-1", + title: "Test", + modelId: "test-openai", + createtime: Date.now(), + updatetime: Date.now(), + }; + mockRepo.listConversations.mockResolvedValue([conv]); + mockRepo.getMessages.mockResolvedValue([]); + fetchSpy.mockResolvedValueOnce(makeTextResponse("ok")); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "hi", modelId: "test-openai-2" }, + sender + ); + + // conversation 应被保存,modelId 更新为 test-openai-2 + const saveConvCalls = mockRepo.saveConversation.mock.calls; + const modelUpdate = saveConvCalls.find((c: any) => c[0].modelId === "test-openai-2"); + expect(modelUpdate).toBeDefined(); + }); + + it("conversation 不存在时 sendEvent error", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([]); // 空 + + 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"); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0].message).toContain("Conversation not found"); + }); + + it("skill 预加载:历史消息含 load_skill 调用时预执行以标记 skill 已加载", async () => { + const { service, mockRepo, mockSkillRepo } = createTestService(); + const { sender } = createMockSender(); + + // 设置 skill + const skill = makeSkillRecord({ + name: "web-skill", + toolNames: ["web-tool"], + prompt: "Web instructions.", + }); + (service as any).skillCache.set("web-skill", skill); + + const toolRecord = makeSkillScriptRecord({ + name: "web-tool", + description: "Web tool", + params: [], + }); + mockSkillRepo.getSkillScripts.mockResolvedValueOnce([toolRecord]); + + const conv = { + id: "conv-1", + title: "Test", + modelId: "test-openai", + skills: "auto" as const, + createtime: Date.now(), + updatetime: Date.now(), + }; + mockRepo.listConversations.mockResolvedValue([conv]); + // 历史中含 load_skill 调用 + mockRepo.getMessages.mockResolvedValue([ + { + id: "u1", + conversationId: "conv-1", + role: "user", + content: "帮我查网页", + createtime: 1000, + }, + { + id: "a1", + conversationId: "conv-1", + role: "assistant", + content: "", + toolCalls: [{ id: "tc1", name: "load_skill", arguments: '{"skill_name":"web-skill"}' }], + createtime: 1001, + }, + { + id: "t1", + conversationId: "conv-1", + role: "tool", + content: "Web instructions.", + toolCallId: "tc1", + createtime: 1002, + }, + ]); + fetchSpy.mockResolvedValueOnce(makeTextResponse("ok")); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "继续" }, sender); + + // getSkillScripts 应被调用以预加载 web-skill 的脚本描述 + expect(mockSkillRepo.getSkillScripts).toHaveBeenCalledWith("web-skill"); + + // 发送给 LLM 的工具列表应包含 execute_skill_script(而非动态注册的独立工具) + const requestBody = JSON.parse(fetchSpy.mock.calls[0][1].body); + const toolNames = requestBody.tools?.map((t: any) => t.function?.name || t.name) || []; + expect(toolNames).toContain("execute_skill_script"); + expect(toolNames).toContain("load_skill"); + }); +}); + +// ---- AgentService.handleDomApi 测试 ---- + +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"); + }); +}); + +// ---- handleModelApi 测试 ---- + +describe.concurrent("handleModelApi", () => { + it.concurrent("list 应返回去掉 apiKey 的模型列表", async () => { + const { service, mockModelRepo } = createTestService(); + mockModelRepo.listModels.mockResolvedValueOnce([ + { + id: "m1", + name: "GPT-4o", + provider: "openai", + apiBaseUrl: "https://api.openai.com", + apiKey: "sk-secret", + model: "gpt-4o", + }, + { + id: "m2", + name: "Claude", + provider: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + apiKey: "ant-secret", + model: "claude-sonnet-4-20250514", + maxTokens: 4096, + }, + ]); + + const result = await service.handleModelApi({ action: "list", scriptUuid: "test" }); + expect(Array.isArray(result)).toBe(true); + const models = result as any[]; + expect(models).toHaveLength(2); + + // apiKey 必须被剥离 + for (const m of models) { + expect(m).not.toHaveProperty("apiKey"); + } + + // 其他字段保留 + expect(models[0]).toEqual({ + id: "m1", + name: "GPT-4o", + provider: "openai", + apiBaseUrl: "https://api.openai.com", + model: "gpt-4o", + supportsVision: true, + supportsImageOutput: true, + }); + expect(models[1]).toEqual({ + id: "m2", + name: "Claude", + provider: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + model: "claude-sonnet-4-20250514", + maxTokens: 4096, + supportsVision: true, + supportsImageOutput: false, + }); + }); + + it.concurrent("get 存在的模型应返回去掉 apiKey 的结果", async () => { + const { service, mockModelRepo } = createTestService(); + mockModelRepo.getModel.mockResolvedValueOnce({ + id: "m1", + name: "GPT-4o", + provider: "openai", + apiBaseUrl: "https://api.openai.com", + apiKey: "sk-secret", + model: "gpt-4o", + }); + + const result = await service.handleModelApi({ action: "get", id: "m1", scriptUuid: "test" }); + expect(result).not.toBeNull(); + expect(result).not.toHaveProperty("apiKey"); + expect((result as any).id).toBe("m1"); + }); + + it.concurrent("get 不存在的模型应返回 null", async () => { + const { service, mockModelRepo } = createTestService(); + mockModelRepo.getModel.mockResolvedValueOnce(undefined); + + const result = await service.handleModelApi({ action: "get", id: "nonexistent", scriptUuid: "test" }); + expect(result).toBeNull(); + }); + + it.concurrent("getDefault 应返回默认模型 ID", async () => { + const { service, mockModelRepo } = createTestService(); + mockModelRepo.getDefaultModelId.mockResolvedValueOnce("m1"); + + const result = await service.handleModelApi({ action: "getDefault", scriptUuid: "test" }); + expect(result).toBe("m1"); + }); + + it.concurrent("未知 action 应抛出错误", async () => { + const { service } = createTestService(); + + await expect(service.handleModelApi({ action: "unknown" as any, scriptUuid: "test" })).rejects.toThrow( + "Unknown model API action" + ); + }); +}); diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts new file mode 100644 index 000000000..1b3d12281 --- /dev/null +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -0,0 +1,548 @@ +import type { IGetSender } from "@Packages/message/server"; +import { GetSenderType } from "@Packages/message/server"; +import type { + AgentModelConfig, + ChatRequest, + ChatStreamEvent, + Conversation, + ConversationApiRequest, + MessageContent, + ToolDefinition, +} from "@App/app/service/agent/core/types"; +import type { ScriptToolCallback, ToolExecutor } from "@App/app/service/agent/core/tool_registry"; +import type { ToolCall } from "@App/app/service/agent/core/types"; +import { agentChatRepo } from "@App/app/repo/agent_chat"; +import type { ToolRegistry } from "@App/app/service/agent/core/tool_registry"; +import type { SkillService } from "./skill_service"; +import type { CompactService } from "./compact_service"; +import type { BackgroundSessionManager, ListenerEntry, RunningConversation } from "./background_session_manager"; +import type { AgentModelService } from "./model_service"; +import type { SubAgentService } from "./sub_agent_service"; +import type { ToolLoopOrchestrator } from "./tool_loop_orchestrator"; +import type { SubAgentRunOptions } from "@App/app/service/agent/core/tools/sub_agent"; +import { buildSystemPrompt } from "@App/app/service/agent/core/system_prompt"; +import { + COMPACT_SYSTEM_PROMPT, + buildCompactUserPrompt, + extractSummary, +} from "@App/app/service/agent/core/compact_prompt"; +import { createTaskTools } from "@App/app/service/agent/core/tools/task_tools"; +import { createAskUserTool } from "@App/app/service/agent/core/tools/ask_user"; +import { createSubAgentTool } from "@App/app/service/agent/core/tools/sub_agent"; +import { createExecuteScriptTool } from "@App/app/service/agent/core/tools/execute_script"; +import { resolveSubAgentType } from "@App/app/service/agent/core/sub_agent_types"; +import { classifyErrorCode } from "./retry_utils"; +import { getTextContent } from "@App/app/service/agent/core/content_utils"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { LLMCallResult } from "./llm_client"; + +/** ChatService 需要的 execute_script 工具依赖 */ +export interface ChatServiceExecuteScriptDeps { + executeInPage: (code: string, options?: { tabId?: number }) => Promise<{ result: unknown; tabId: number }>; + executeInSandbox: (code: string) => Promise; +} + +/** ChatService 需要的 LLM 调用依赖 */ +export interface ChatServiceLLMDeps { + callLLM: ( + model: AgentModelConfig, + params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ) => Promise; + callLLMWithToolLoop: (params: Parameters[0]) => Promise; +} + +export class ChatService { + constructor( + private toolRegistry: ToolRegistry, + private modelService: AgentModelService, + private skillService: SkillService, + private compactService: CompactService, + private bgSessionManager: BackgroundSessionManager, + private subAgentService: SubAgentService, + private executeScriptDeps: ChatServiceExecuteScriptDeps, + private llmDeps: ChatServiceLLMDeps + ) {} + + // 处理 Sandbox conversation API 请求(非流式) + async handleConversation(params: ConversationApiRequest): Promise { + switch (params.action) { + case "create": + return this.createConversation(params); + case "get": + return this.getConversation(params.id); + case "getMessages": + return agentChatRepo.getMessages(params.conversationId); + case "save": + // 对话已经在 chat 过程中持久化,这里确保元数据也保存 + return true; + case "clearMessages": + await agentChatRepo.saveMessages(params.conversationId, []); + return true; + default: + throw new Error(`Unknown conversation action: ${(params as any).action}`); + } + } + + private async createConversation(params: Extract) { + const model = await this.modelService.getModel(params.options.model); + const conv: Conversation = { + id: params.options.id || uuidv4(), + title: "New Chat", + modelId: model.id, + system: params.options.system, + skills: params.options.skills, + createtime: Date.now(), + updatetime: Date.now(), + }; + await agentChatRepo.saveConversation(conv); + return conv; + } + + private async getConversation(id: string): Promise { + const conversations = await agentChatRepo.listConversations(); + return conversations.find((c) => c.id === id) || null; + } + + // 统一的流式 conversation chat(UI 和脚本 API 共用) + async handleConversationChat( + params: { + conversationId: string; + message: MessageContent; + tools?: ToolDefinition[]; + maxIterations?: number; + scriptUuid?: string; + modelId?: string; + enableTools?: boolean; // 是否携带 tools,undefined 表示不覆盖 + // 用户消息已在存储中(重新生成场景),跳过保存和 LLM 上下文追加 + skipSaveUserMessage?: boolean; + // ephemeral 会话专用字段 + ephemeral?: boolean; + messages?: ChatRequest["messages"]; + system?: string; + cache?: boolean; + // compact 模式 + compact?: boolean; + compactInstruction?: string; + // 后台运行模式 + background?: boolean; + }, + sender: IGetSender + ) { + if (!sender.isType(GetSenderType.CONNECT)) { + throw new Error("Conversation chat requires connect mode"); + } + const msgConn = sender.getConnect()!; + + // 后台模式:非 ephemeral、非 compact 时可用 + const isBackground = params.background === true && !params.ephemeral && !params.compact; + + // 检查是否已有后台运行的同一会话 + if (isBackground && this.bgSessionManager.has(params.conversationId)) { + msgConn.sendMessage({ + action: "event", + data: { type: "error", message: "会话正在运行中" } as ChatStreamEvent, + }); + return; + } + + const abortController = new AbortController(); + let isDisconnected = false; + + // 后台模式:创建 RunningConversation + let rc: RunningConversation | undefined; + if (isBackground) { + rc = { + conversationId: params.conversationId, + abortController, + listeners: new Set(), + streamingState: { content: "", thinking: "", toolCalls: [] }, + askResolvers: new Map(), + tasks: [], + status: "running", + }; + this.bgSessionManager.set(params.conversationId, rc); + } + + // ask_user resolvers(后台模式挂在 rc 上,普通模式本地) + const askResolvers = rc ? rc.askResolvers : new Map void>(); + + const sendEvent = (event: ChatStreamEvent) => { + if (rc) { + // 后台模式:先更新快照,再广播到所有 listener + this.bgSessionManager.updateStreamingState(rc, event); + this.bgSessionManager.broadcastEvent(rc, event); + } else { + if (!isDisconnected) { + msgConn.sendMessage({ action: "event", data: event }); + } + } + }; + + if (rc) { + // 后台模式:初始 listener + const listener: ListenerEntry = { + sendEvent: (event) => { + if (!isDisconnected) { + msgConn.sendMessage({ action: "event", data: event }); + } + }, + }; + rc.listeners.add(listener); + + msgConn.onDisconnect(() => { + isDisconnected = true; + // 后台模式:只移除 listener,不 abort + rc!.listeners.delete(listener); + }); + } else { + msgConn.onDisconnect(() => { + isDisconnected = true; + abortController.abort(); + }); + } + + // 构建脚本工具回调:通过 MessageConnect 让 Sandbox 执行 handler + let toolResultResolve: ((results: Array<{ id: string; result: string }>) => void) | null = null; + + msgConn.onMessage((msg: any) => { + if (msg.action === "toolResults" && toolResultResolve) { + const resolve = toolResultResolve; + toolResultResolve = null; + resolve(msg.data); + } + if (msg.action === "askUserResponse" && msg.data) { + const resolver = askResolvers.get(msg.data.id); + if (resolver) { + askResolvers.delete(msg.data.id); + if (rc) rc.pendingAskUser = undefined; + resolver(msg.data.answer); + } + } + if (msg.action === "stop") { + abortController.abort(); + } + }); + + const scriptToolCallback: ScriptToolCallback = (toolCalls: ToolCall[]) => { + return new Promise((resolve) => { + toolResultResolve = resolve; + msgConn.sendMessage({ action: "executeTools", data: toolCalls }); + }); + }; + + try { + // ephemeral 模式:无状态处理,不从 repo 加载/持久化 + if (params.ephemeral) { + const model = await this.modelService.getModel(params.modelId); + + // 使用脚本传入的完整消息历史 + const messages: ChatRequest["messages"] = []; + + // 添加 system prompt(内置提示词 + 用户自定义) + const ephemeralSystem = buildSystemPrompt({ userSystem: params.system }); + messages.push({ role: "system", content: ephemeralSystem }); + + // 添加脚本端维护的消息历史(已含最新 user message) + if (params.messages) { + for (const msg of params.messages) { + messages.push({ + role: msg.role, + content: msg.content, + toolCallId: msg.toolCallId, + toolCalls: msg.toolCalls, + }); + } + } + + await this.llmDeps.callLLMWithToolLoop({ + model, + messages, + tools: params.tools, + maxIterations: params.maxIterations || 20, + sendEvent, + signal: abortController.signal, + scriptToolCallback: params.tools && params.tools.length > 0 ? scriptToolCallback : null, + skipBuiltinTools: true, + cache: params.cache, + }); + return; + } + + // compact 模式:压缩对话历史 + if (params.compact) { + const conv = await this.getConversation(params.conversationId); + if (!conv) { + sendEvent({ type: "error", message: "Conversation not found" }); + return; + } + + const model = await this.modelService.getModel(params.modelId || conv.modelId); + const existingMessages = await agentChatRepo.getMessages(params.conversationId); + + if (existingMessages.filter((m) => m.role !== "system").length === 0) { + sendEvent({ type: "error", message: "No messages to compact" }); + return; + } + + // 构建摘要请求 + const summaryMessages: ChatRequest["messages"] = []; + summaryMessages.push({ role: "system", content: COMPACT_SYSTEM_PROMPT }); + + for (const msg of existingMessages) { + if (msg.role === "system") continue; + summaryMessages.push({ + role: msg.role, + content: msg.content, + toolCallId: msg.toolCallId, + toolCalls: msg.toolCalls, + }); + } + + summaryMessages.push({ role: "user", content: buildCompactUserPrompt(params.compactInstruction) }); + + // 不带 tools 调用 LLM + const result = await this.llmDeps.callLLM( + model, + { messages: summaryMessages, cache: false }, + sendEvent, + abortController.signal + ); + + const summary = extractSummary(result.content); + const originalCount = existingMessages.length; + + // 用摘要消息替换历史 + const summaryMessage = { + id: uuidv4(), + conversationId: params.conversationId, + role: "user" as const, + content: `[Conversation Summary]\n\n${summary}`, + createtime: Date.now(), + }; + await agentChatRepo.saveMessages(params.conversationId, [summaryMessage]); + + sendEvent({ type: "compact_done", summary, originalCount }); + sendEvent({ type: "done", usage: result.usage }); + return; + } + + // 获取对话和模型 + const conv = await this.getConversation(params.conversationId); + if (!conv) { + sendEvent({ type: "error", message: "Conversation not found" }); + return; + } + + // UI 传入 modelId / enableTools 时覆盖 conversation 的配置 + let needSave = false; + if (params.modelId && params.modelId !== conv.modelId) { + conv.modelId = params.modelId; + needSave = true; + } + if (params.enableTools !== undefined && params.enableTools !== conv.enableTools) { + conv.enableTools = params.enableTools; + needSave = true; + } + if (needSave) { + conv.updatetime = Date.now(); + await agentChatRepo.saveConversation(conv); + } + + const model = await this.modelService.getModel(conv.modelId); + + // enableTools 默认为 true + const enableTools = conv.enableTools !== false; + + // 解析 Skills(注入 prompt + 注册 meta-tools),仅在启用 tools 时执行 + let promptSuffix = ""; + const registeredMetaToolNames: string[] = []; + let metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }> = []; + if (enableTools) { + const resolved = this.skillService.resolveSkills(conv.skills); + promptSuffix = resolved.promptSuffix; + metaTools = resolved.metaTools; + + // 临时注册 skill meta-tools(对话结束后清理) + for (const mt of metaTools) { + this.toolRegistry.registerBuiltin(mt.definition, mt.executor); + registeredMetaToolNames.push(mt.definition.name); + } + + // 注册每次请求的临时工具 + // Task tools(从持久化加载,变更时保存并推送事件到 UI) + const initialTasks = await agentChatRepo.getTasks(params.conversationId); + const { tools: taskToolDefs } = createTaskTools({ + initialTasks, + onSave: (tasks) => agentChatRepo.saveTasks(params.conversationId, tasks), + sendEvent, + }); + for (const t of taskToolDefs) { + this.toolRegistry.registerBuiltin(t.definition, t.executor); + registeredMetaToolNames.push(t.definition.name); + } + + // Ask user + const askTool = createAskUserTool(sendEvent, askResolvers); + this.toolRegistry.registerBuiltin(askTool.definition, askTool.executor); + registeredMetaToolNames.push(askTool.definition.name); + + // Sub-agent + const subAgentTool = createSubAgentTool({ + runSubAgent: (options: SubAgentRunOptions) => { + const agentId = options.to || uuidv4(); + const typeConfig = resolveSubAgentType(options.type); + // 组合父信号和类型配置的超时信号 + const subSignal = AbortSignal.any([abortController.signal, AbortSignal.timeout(typeConfig.timeoutMs)]); + return this.subAgentService.runSubAgent({ + options: { ...options, description: options.description || "Sub-agent task" }, + model, + parentConversationId: params.conversationId, + signal: subSignal, + sendEvent: (evt) => + sendEvent({ + type: "sub_agent_event", + agentId, + description: options.description || "Sub-agent task", + subAgentType: typeConfig.name, + event: evt, + }), + }); + }, + }); + this.toolRegistry.registerBuiltin(subAgentTool.definition, subAgentTool.executor); + registeredMetaToolNames.push(subAgentTool.definition.name); + + // Execute script + const executeScriptTool = createExecuteScriptTool(this.executeScriptDeps); + this.toolRegistry.registerBuiltin(executeScriptTool.definition, executeScriptTool.executor); + registeredMetaToolNames.push(executeScriptTool.definition.name); + } + + // 加载历史消息 + const existingMessages = await agentChatRepo.getMessages(params.conversationId); + + // 扫描历史消息中的 load_skill 调用,预加载之前已加载的 skill 的工具 + if (enableTools && metaTools.length > 0) { + const loadSkillMeta = metaTools.find((mt) => mt.definition.name === "load_skill"); + if (loadSkillMeta) { + const loadedSkillNames = new Set(); + for (const msg of existingMessages) { + if (msg.role === "assistant" && msg.toolCalls) { + for (const tc of msg.toolCalls) { + if (tc.name === "load_skill") { + try { + const args = JSON.parse(tc.arguments || "{}"); + if (args.skill_name) { + loadedSkillNames.add(args.skill_name); + } + } catch { + // 解析失败,跳过 + } + } + } + } + } + // 预执行 load_skill 以注册动态工具(结果不需要,只需要副作用) + for (const skillName of loadedSkillNames) { + try { + await loadSkillMeta.executor.execute({ skill_name: skillName }); + } catch { + // 加载失败,跳过 + } + } + } + } + + // 构建消息列表 + const messages: ChatRequest["messages"] = []; + + // 添加 system 消息(内置提示词 + 用户自定义 + skill prompt) + const systemContent = buildSystemPrompt({ + userSystem: conv.system, + skillSuffix: enableTools ? promptSuffix : undefined, + }); + messages.push({ role: "system", content: systemContent }); + + // 添加历史消息(跳过 system) + for (const msg of existingMessages) { + if (msg.role === "system") continue; + messages.push({ + role: msg.role, + content: msg.content, + toolCallId: msg.toolCallId, + toolCalls: msg.toolCalls, + }); + } + + if (!params.skipSaveUserMessage) { + // 添加新用户消息到 LLM 上下文并持久化 + messages.push({ role: "user", content: params.message }); + await agentChatRepo.appendMessage({ + id: uuidv4(), + conversationId: params.conversationId, + role: "user", + content: params.message, + createtime: Date.now(), + }); + } + + // 更新对话标题(如果是第一条消息) + if (existingMessages.length === 0 && conv.title === "New Chat") { + const titleText = getTextContent(params.message); + conv.title = titleText.slice(0, 30) + (titleText.length > 30 ? "..." : ""); + conv.updatetime = Date.now(); + await agentChatRepo.saveConversation(conv); + } + + try { + // 使用统一的 tool calling 循环 + await this.llmDeps.callLLMWithToolLoop({ + model, + messages, + tools: enableTools ? params.tools : undefined, + maxIterations: params.maxIterations || 50, + sendEvent, + signal: abortController.signal, + scriptToolCallback: enableTools && params.tools && params.tools.length > 0 ? scriptToolCallback : null, + conversationId: params.conversationId, + skipBuiltinTools: !enableTools, + }); + // 后台模式:正常完成后延迟清理 + this.bgSessionManager.cleanupIfDone(params.conversationId); + } finally { + // 清理临时注册的 meta-tools + for (const name of registeredMetaToolNames) { + this.toolRegistry.unregisterBuiltin(name); + } + // 清理子代理上下文缓存 + this.subAgentService.cleanup(params.conversationId); + } + } catch (e: any) { + // 后台模式:abort 也需要清理注册表 + if (abortController.signal.aborted) { + this.bgSessionManager.cleanupIfDone(params.conversationId); + return; + } + const errorMsg = e.message || "Unknown error"; + // 持久化错误消息到 OPFS,确保刷新后仍可见 + if (params.conversationId && !params.ephemeral) { + try { + await agentChatRepo.appendMessage({ + id: uuidv4(), + conversationId: params.conversationId, + role: "assistant", + content: "", + error: errorMsg, + createtime: Date.now(), + }); + } catch { + // 持久化失败不阻塞错误事件发送 + } + } + sendEvent({ type: "error", message: errorMsg, errorCode: classifyErrorCode(e) }); + this.bgSessionManager.cleanupIfDone(params.conversationId); + } + } +} diff --git a/src/app/service/agent/service_worker/compact_service.ts b/src/app/service/agent/service_worker/compact_service.ts new file mode 100644 index 000000000..163c6e969 --- /dev/null +++ b/src/app/service/agent/service_worker/compact_service.ts @@ -0,0 +1,127 @@ +import { agentChatRepo } from "@App/app/repo/agent_chat"; +import type { + AgentModelConfig, + ChatRequest, + ChatStreamEvent, + ToolCall, + ContentBlock, + ToolDefinition, +} from "@App/app/service/agent/core/types"; +import { + COMPACT_SYSTEM_PROMPT, + buildCompactUserPrompt, + extractSummary, +} from "@App/app/service/agent/core/compact_prompt"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { AgentModelService } from "./model_service"; + +/** LLM 调用结果(与 AgentService.callLLM 返回值一致) */ +interface CompactLLMResult { + content: string; + thinking?: string; + toolCalls?: ToolCall[]; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + contentBlocks?: ContentBlock[]; +} + +/** 供 CompactService 调用的 orchestrator 能力(最小 LLM 调用接口) */ +export interface CompactOrchestrator { + callLLM( + model: AgentModelConfig, + params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ): Promise; +} + +export class CompactService { + constructor( + private modelService: AgentModelService, + private orchestrator: CompactOrchestrator + ) {} + + /** 自动 compact:汇总对话历史为 summary 并替换 currentMessages */ + async autoCompact( + conversationId: string, + model: AgentModelConfig, + currentMessages: ChatRequest["messages"], + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ): Promise { + // 构建摘要请求(用 currentMessages 而非从 repo 加载,因为可能有未持久化的 tool 消息) + const summaryMessages: ChatRequest["messages"] = []; + summaryMessages.push({ role: "system", content: COMPACT_SYSTEM_PROMPT }); + + for (const msg of currentMessages) { + if (msg.role === "system") continue; + summaryMessages.push(msg); + } + summaryMessages.push({ role: "user", content: buildCompactUserPrompt() }); + + // 调用 LLM 获取摘要(不带 tools,不发流式事件给 UI) + const noopSendEvent = () => {}; + const result = await this.orchestrator.callLLM( + model, + { messages: summaryMessages, cache: false }, + noopSendEvent, + signal + ); + + const summary = extractSummary(result.content); + + // 替换 currentMessages(保留 system,替换其余为摘要) + const systemMsg = currentMessages.find((m) => m.role === "system"); + currentMessages.length = 0; + if (systemMsg) currentMessages.push(systemMsg); + currentMessages.push({ role: "user", content: `[Conversation Summary]\n\n${summary}` }); + + // 持久化 + const summaryMessage = { + id: uuidv4(), + conversationId, + role: "user" as const, + content: `[Conversation Summary]\n\n${summary}`, + createtime: Date.now(), + }; + await agentChatRepo.saveMessages(conversationId, [summaryMessage]); + + // 通知 UI + sendEvent({ type: "compact_done", summary, originalCount: -1 }); + } + + /** 使用 summary 模型对任意内容做提取/总结(供 tab 工具使用) */ + async summarizeContent(content: string, prompt: string): Promise { + const model = await this.modelService.getSummaryModel(); + + const messages: ChatRequest["messages"] = [ + { + role: "system" as const, + content: + "Extract or summarize the relevant information from the provided web page content based on the user's request. Return only the relevant content without any explanation or commentary.", + }, + { + role: "user" as const, + content: `${prompt}\n\n---\n\n${content}`, + }, + ]; + + const noopSendEvent = () => {}; + const controller = new AbortController(); + try { + const result = await this.orchestrator.callLLM( + model, + { messages, cache: false }, + noopSendEvent, + controller.signal + ); + return result.content; + } catch (e: any) { + throw new Error(`Summarization failed: ${e.message}`); + } + } +} diff --git a/src/app/service/agent/service_worker/dom.test.ts b/src/app/service/agent/service_worker/dom.test.ts new file mode 100644 index 000000000..a0cf6829d --- /dev/null +++ b/src/app/service/agent/service_worker/dom.test.ts @@ -0,0 +1,454 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { AgentDomService } from "./dom"; +import { assertDomUrlAllowed, SENSITIVE_HOST_PATTERNS } from "./dom_policy"; + +// mock chrome.scripting +const mockExecuteScript = vi.fn(); +// mock chrome.tabs +const mockTabsQuery = vi.fn(); +const mockTabsGet = vi.fn(); +const mockTabsCreate = vi.fn(); +const mockTabsUpdate = vi.fn(); +const mockTabsReload = vi.fn(); +const mockCaptureVisibleTab = vi.fn(); +const mockOnUpdated = { + addListener: vi.fn(), + removeListener: vi.fn(), +}; +const mockOnCreated = { + addListener: vi.fn(), + removeListener: vi.fn(), +}; +const mockOnRemoved = { + addListener: vi.fn(), + removeListener: vi.fn(), +}; + +// mock chrome.permissions +const mockPermissionsContains = vi.fn(); +const mockPermissionsRequest = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + + // 设置 chrome.scripting mock + (chrome as any).scripting = { + executeScript: mockExecuteScript, + }; + // 覆盖 chrome.tabs mock + (chrome.tabs as any).query = mockTabsQuery; + (chrome.tabs as any).get = mockTabsGet; + (chrome.tabs as any).create = mockTabsCreate; + (chrome.tabs as any).update = mockTabsUpdate; + (chrome.tabs as any).reload = mockTabsReload; + (chrome.tabs as any).captureVisibleTab = mockCaptureVisibleTab; + (chrome.tabs as any).onUpdated = mockOnUpdated; + (chrome.tabs as any).onCreated = mockOnCreated; + (chrome.tabs as any).onRemoved = mockOnRemoved; + (chrome as any).permissions = { + contains: mockPermissionsContains, + request: mockPermissionsRequest, + }; +}); + +// ---- 守卫单元测试 ---- +describe("assertDomUrlAllowed", () => { + it("应拒绝空字符串", () => { + expect(() => assertDomUrlAllowed("")).toThrow("Agent DOM operation not allowed for URL: (empty)"); + }); + + it("应拒绝 chrome:// URL", () => { + expect(() => assertDomUrlAllowed("chrome://settings")).toThrow("Agent DOM operation not allowed for URL:"); + }); + + it("应拒绝 chrome-extension:// URL", () => { + expect(() => assertDomUrlAllowed("chrome-extension://abc123/popup.html")).toThrow( + "Agent DOM operation not allowed for URL:" + ); + }); + + it("应拒绝 edge:// URL", () => { + expect(() => assertDomUrlAllowed("edge://newtab")).toThrow("Agent DOM operation not allowed for URL:"); + }); + + it("应拒绝 about:newtab(非 about:blank)", () => { + expect(() => assertDomUrlAllowed("about:newtab")).toThrow("Agent DOM operation not allowed for URL:"); + }); + + it("应拒绝 devtools:// URL", () => { + expect(() => assertDomUrlAllowed("devtools://devtools/bundled/devtools_app.html")).toThrow( + "Agent DOM operation not allowed for URL:" + ); + }); + + it("应拒绝 view-source: URL", () => { + expect(() => assertDomUrlAllowed("view-source:https://example.com")).toThrow( + "Agent DOM operation not allowed for URL:" + ); + }); + + it("应允许 about:blank(新标签页)", () => { + expect(() => assertDomUrlAllowed("about:blank")).not.toThrow(); + }); + + it("应允许正常 https URL", () => { + expect(() => assertDomUrlAllowed("https://example.com/path")).not.toThrow(); + }); + + it("应允许正常 http URL", () => { + expect(() => assertDomUrlAllowed("http://localhost:3000")).not.toThrow(); + }); + + it("SENSITIVE_HOST_PATTERNS 应包含浏览器内部协议", () => { + expect(SENSITIVE_HOST_PATTERNS).toContain("chrome://"); + expect(SENSITIVE_HOST_PATTERNS).toContain("chrome-extension://"); + expect(SENSITIVE_HOST_PATTERNS).toContain("edge://"); + expect(SENSITIVE_HOST_PATTERNS).toContain("about:"); + expect(SENSITIVE_HOST_PATTERNS).toContain("devtools://"); + expect(SENSITIVE_HOST_PATTERNS).toContain("view-source:"); + }); +}); + +describe("AgentDomService", () => { + let service: AgentDomService; + + beforeEach(() => { + service = new AgentDomService(); + }); + + describe("listTabs", () => { + it("应返回所有标签页信息", async () => { + mockTabsQuery.mockResolvedValue([ + { id: 1, url: "https://example.com", title: "Example", active: true, windowId: 1, discarded: false }, + { id: 2, url: "https://test.com", title: "Test", active: false, windowId: 1, discarded: false }, + ]); + + const result = await service.listTabs(); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + tabId: 1, + url: "https://example.com", + title: "Example", + active: true, + windowId: 1, + discarded: false, + }); + expect(result[1].tabId).toBe(2); + }); + + it("应过滤没有 id 的标签页", async () => { + mockTabsQuery.mockResolvedValue([ + { id: 1, url: "https://example.com", title: "Example", active: true, windowId: 1 }, + { url: "https://no-id.com", title: "No ID", active: false, windowId: 1 }, + ]); + + const result = await service.listTabs(); + expect(result).toHaveLength(1); + expect(result[0].tabId).toBe(1); + }); + }); + + describe("navigate", () => { + it("应在指定 tabId 时更新标签页", async () => { + mockTabsUpdate.mockResolvedValue({}); + mockTabsGet.mockResolvedValue({ + id: 1, + url: "https://new-url.com", + title: "New Page", + status: "complete", + }); + + const result = await service.navigate("https://new-url.com", { tabId: 1, waitUntil: false }); + + expect(mockTabsUpdate).toHaveBeenCalledWith(1, { url: "https://new-url.com" }); + expect(result.tabId).toBe(1); + expect(result.url).toBe("https://new-url.com"); + }); + + it("应在未指定 tabId 时创建新标签页", async () => { + mockTabsCreate.mockResolvedValue({ id: 5 }); + mockTabsGet.mockResolvedValue({ + id: 5, + url: "https://new-url.com", + title: "New Page", + status: "complete", + }); + + const result = await service.navigate("https://new-url.com", { waitUntil: false }); + + expect(mockTabsCreate).toHaveBeenCalledWith({ url: "https://new-url.com" }); + expect(result.tabId).toBe(5); + }); + + it("应拒绝导航到 chrome:// URL", async () => { + await expect(service.navigate("chrome://settings")).rejects.toThrow("Agent DOM operation not allowed for URL:"); + }); + + it("应拒绝导航到 chrome-extension:// URL", async () => { + await expect(service.navigate("chrome-extension://abc/popup.html")).rejects.toThrow( + "Agent DOM operation not allowed for URL:" + ); + }); + }); + + describe("readPage", () => { + it("应返回页面 HTML", async () => { + const mockPageContent = { + title: "Test Page", + url: "https://example.com", + html: "

    Hello

    ", + }; + mockExecuteScript.mockResolvedValue([{ result: mockPageContent }]); + mockTabsQuery.mockResolvedValue([{ id: 1 }]); + mockTabsGet.mockResolvedValue({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + + const result = await service.readPage({ tabId: 1 }); + + expect(result.title).toBe("Test Page"); + expect(result.html).toContain("

    Hello

    "); + expect(mockExecuteScript).toHaveBeenCalledWith( + expect.objectContaining({ + target: { tabId: 1 }, + world: "ISOLATED", + }) + ); + }); + + it("应在 HTML 超长时截断", async () => { + const mockPageContent = { + title: "Test Page", + url: "https://example.com", + html: "truncated...", + truncated: true, + totalLength: 500000, + }; + mockExecuteScript.mockResolvedValue([{ result: mockPageContent }]); + mockTabsGet.mockResolvedValue({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + + const result = await service.readPage({ tabId: 1 }); + + expect(result.truncated).toBe(true); + expect(result.totalLength).toBe(500000); + }); + + it("应拒绝操作 chrome:// tab", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "chrome://newtab", status: "complete", discarded: false }); + + await expect(service.readPage({ tabId: 1 })).rejects.toThrow("Agent DOM operation not allowed for URL:"); + }); + }); + + describe("click", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("应执行默认模式点击", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + // 点击执行 + mockExecuteScript.mockResolvedValueOnce([{ result: undefined }]); + + const promise = service.click("#btn", { tabId: 1 }); + await vi.advanceTimersByTimeAsync(600); + const result = await promise; + + expect(result.success).toBe(true); + expect(mockExecuteScript).toHaveBeenCalledTimes(1); + }); + + it("应检测页面跳转", async () => { + // 第一次 get 返回原始 URL(resolveTabId) + mockTabsGet.mockResolvedValueOnce({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + // 第二次 get 返回原始 URL(executeClick 内部) + mockTabsGet.mockResolvedValueOnce({ id: 1, url: "https://example.com", status: "complete" }); + mockExecuteScript.mockResolvedValueOnce([{ result: undefined }]); // 点击 + // 第三次 get 返回新 URL(collectActionResult) + mockTabsGet.mockResolvedValueOnce({ id: 1, url: "https://new-page.com", status: "complete" }); + + const promise = service.click("#link", { tabId: 1 }); + await vi.advanceTimersByTimeAsync(600); + const result = await promise; + + expect(result.success).toBe(true); + expect(result.navigated).toBe(true); + expect(result.url).toBe("https://new-page.com"); + }); + }); + + describe("fill", () => { + it("应执行默认模式填写", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + mockExecuteScript.mockResolvedValue([{ result: undefined }]); + + const result = await service.fill("#input", "test value", { tabId: 1 }); + + expect(result.success).toBe(true); + expect(result.url).toBe("https://example.com"); + }); + }); + + describe("scroll", () => { + it("应返回滚动位置信息", async () => { + const scrollResult = { + scrollTop: 800, + scrollHeight: 5000, + clientHeight: 900, + atBottom: false, + }; + mockExecuteScript.mockResolvedValue([{ result: scrollResult }]); + mockTabsGet.mockResolvedValue({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + + const result = await service.scroll("down", { tabId: 1 }); + + expect(result.scrollTop).toBe(800); + expect(result.atBottom).toBe(false); + }); + }); + + describe("waitFor", () => { + it("应在元素存在时立即返回", async () => { + const waitResult = { + found: true, + element: { + selector: "#target", + tag: "div", + text: "Found", + visible: true, + }, + }; + mockExecuteScript.mockResolvedValue([{ result: waitResult }]); + mockTabsGet.mockResolvedValue({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + + const result = await service.waitFor("#target", { tabId: 1 }); + + expect(result.found).toBe(true); + expect(result.element?.tag).toBe("div"); + }); + + it("应在超时后返回 found: false", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + mockExecuteScript.mockResolvedValue([{ result: null }]); + mockTabsGet.mockResolvedValue({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + + const promise = service.waitFor("#nonexistent", { tabId: 1, timeout: 100 }); + // 需要多次 advance 来驱动 while 循环中的 setTimeout + for (let i = 0; i < 5; i++) { + await vi.advanceTimersByTimeAsync(600); + } + const result = await promise; + + expect(result.found).toBe(false); + vi.useRealTimers(); + }); + }); + + describe("screenshot", () => { + it("应在前台 tab 使用 captureVisibleTab", async () => { + mockTabsGet.mockResolvedValue({ + id: 1, + url: "https://example.com", + active: true, + windowId: 1, + status: "complete", + discarded: false, + }); + mockCaptureVisibleTab.mockResolvedValue("data:image/jpeg;base64,abc123"); + + const result = await service.screenshot({ tabId: 1 }); + + expect(result.dataUrl).toBe("data:image/jpeg;base64,abc123"); + expect(mockCaptureVisibleTab).toHaveBeenCalledWith(1, { format: "jpeg", quality: 80 }); + }); + }); + + describe("executeScript", () => { + it("应在页面中执行代码并返回结果", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + mockExecuteScript.mockResolvedValue([{ result: { count: 42, items: ["a", "b"] } }]); + + const result = await service.executeScript('return document.querySelectorAll("a").length', { tabId: 1 }); + + expect(result).toEqual({ result: { count: 42, items: ["a", "b"] }, tabId: 1 }); + expect(mockExecuteScript).toHaveBeenCalledWith( + expect.objectContaining({ + target: { tabId: 1 }, + world: "MAIN", + }) + ); + }); + + it("应在执行失败时抛出错误", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "https://example.com", status: "complete", discarded: false }); + mockExecuteScript.mockResolvedValue([]); + + await expect(service.executeScript("return 1", { tabId: 1 })).rejects.toThrow("Failed to execute script"); + }); + }); + + describe("handleDomApi", () => { + it("应正确路由 listTabs 请求", async () => { + mockTabsQuery.mockResolvedValue([]); + + const result = await service.handleDomApi({ action: "listTabs", scriptUuid: "test" }); + + expect(result).toEqual([]); + }); + + it("应对未知 action 抛出错误", async () => { + await expect(service.handleDomApi({ action: "unknown" as any, scriptUuid: "test" })).rejects.toThrow( + "Unknown DOM action" + ); + }); + }); + + describe("resolveTabId", () => { + it("应在 tab 被 discard 时自动 reload", async () => { + mockTabsGet.mockResolvedValueOnce({ + id: 1, + url: "https://example.com", + discarded: true, + status: "complete", + }); + mockTabsReload.mockResolvedValue(undefined); + // reload 后再次 get + mockTabsGet.mockResolvedValueOnce({ + id: 1, + url: "https://example.com", + discarded: false, + status: "complete", + }); + + // 通过 readPage 间接测试 resolveTabId + const mockContent = { + title: "Test", + url: "https://example.com", + html: "Test", + }; + mockExecuteScript.mockResolvedValue([{ result: mockContent }]); + + await service.readPage({ tabId: 1 }); + + expect(mockTabsReload).toHaveBeenCalledWith(1); + }); + + it("应在无活动 tab 时抛出错误", async () => { + mockTabsQuery.mockResolvedValue([]); + mockExecuteScript.mockResolvedValue([{ result: {} }]); + + await expect(service.readPage()).rejects.toThrow("No active tab found"); + }); + + it("应允许操作 about:blank tab(新标签页)", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "about:blank", status: "complete", discarded: false }); + const mockContent = { title: "", url: "about:blank", html: "" }; + mockExecuteScript.mockResolvedValue([{ result: mockContent }]); + + await expect(service.readPage({ tabId: 1 })).resolves.toBeDefined(); + }); + }); +}); diff --git a/src/app/service/agent/service_worker/dom.ts b/src/app/service/agent/service_worker/dom.ts new file mode 100644 index 000000000..b280d9e61 --- /dev/null +++ b/src/app/service/agent/service_worker/dom.ts @@ -0,0 +1,577 @@ +// AgentDomService — DOM 操作核心逻辑,在 Service Worker 中运行 +// 默认模式通过 chrome.scripting.executeScript 操作 +// trusted 模式通过 chrome.debugger CDP 操作 + +import type { + TabInfo, + ActionResult, + PageContent, + ReadPageOptions, + DomActionOptions, + ScreenshotOptions, + ScreenshotResult, + NavigateOptions, + ScrollDirection, + ScrollOptions, + ScrollResult, + NavigateResult, + WaitForOptions, + WaitForResult, + DomApiRequest, + ExecuteScriptOptions, +} from "@App/app/service/agent/core/types"; +import { decodeDataUrl, writeWorkspaceFile } from "@App/app/service/agent/core/opfs_helpers"; +import { assertDomUrlAllowed } from "./dom_policy"; + +type ReadPageInjectedOptions = { + selector: string | undefined | null; + maxLength: number; + removeTags: string[]; +}; +import type { MonitorResult, MonitorStatus } from "@App/app/service/agent/core/types"; +import { + withDebugger, + cdpClick, + cdpFill, + cdpScreenshot, + cdpStartMonitor, + cdpStopMonitor, + cdpPeekMonitor, +} from "./dom_cdp"; + +export class AgentDomService { + // 列出所有标签页 + async listTabs(): Promise { + const tabs = await chrome.tabs.query({}); + return tabs + .filter((t) => t.id !== undefined) + .map((t) => ({ + tabId: t.id!, + url: t.url || "", + title: t.title || "", + active: t.active || false, + windowId: t.windowId, + discarded: t.discarded || false, + })); + } + + // 导航到 URL + async navigate(url: string, options?: NavigateOptions): Promise { + // 校验目标 URL 是否在黑名单中 + assertDomUrlAllowed(url); + const timeout = options?.timeout ?? 30000; + const waitUntil = options?.waitUntil ?? true; + + let tabId: number; + if (options?.tabId) { + await chrome.tabs.update(options.tabId, { url }); + tabId = options.tabId; + } else { + const tab = await chrome.tabs.create({ url }); + tabId = tab.id!; + } + + if (waitUntil) { + await this.waitForPageLoad(tabId, timeout); + } + + const tab = await chrome.tabs.get(tabId); + return { + tabId, + url: tab.url || url, + title: tab.title || "", + }; + } + + // 读取页面内容,返回原始 HTML + async readPage(options?: ReadPageOptions): Promise { + const tabId = await this.resolveTabId(options?.tabId); + const maxLength = options?.maxLength ?? 200000; + const selector = options?.selector; + const removeTags = options?.removeTags ?? ["script", "style", "noscript", "svg", "link[rel=stylesheet]"]; + + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: readPageContent, + args: [{ selector, maxLength, removeTags } as ReadPageInjectedOptions], + world: "ISOLATED", + }); + + if (!results || results.length === 0) { + throw new Error("Failed to read page content"); + } + + return results[0].result as PageContent; + } + + // 截图 + async screenshot(options?: ScreenshotOptions): Promise { + const tabId = await this.resolveTabId(options?.tabId); + let dataUrl: string; + + // 指定 selector 区域截图时,必须走 CDP + if (options?.selector) { + dataUrl = await withDebugger(tabId, (id) => cdpScreenshot(id, options)); + } else { + // 检查 tab 是否前台 active + const tab = await chrome.tabs.get(tabId); + if (!tab.active) { + // 后台 tab 优先用 CDP 截图 + try { + dataUrl = await withDebugger(tabId, (id) => cdpScreenshot(id, options)); + } catch (e) { + console.error("[AgentDom] CDP screenshot failed, falling back to captureVisibleTab", { + tabId, + error: e instanceof Error ? e.message : e, + }); + // 降级:先激活 tab 再用 captureVisibleTab + await chrome.tabs.update(tabId, { active: true }); + await new Promise((resolve) => setTimeout(resolve, 300)); + dataUrl = await this.captureVisibleTab(tabId, options); + } + } else { + dataUrl = await this.captureVisibleTab(tabId, options); + } + } + + const result: ScreenshotResult = { dataUrl }; + + // saveTo: 将截图保存到 OPFS workspace + if (options?.saveTo) { + const { data } = decodeDataUrl(dataUrl); + const saved = await writeWorkspaceFile(options.saveTo, data); + result.path = saved.path; + result.size = saved.size; + } + + return result; + } + + private async captureVisibleTab(tabId: number, options?: ScreenshotOptions): Promise { + const quality = options?.quality ?? 80; + const tab = await chrome.tabs.get(tabId); + return chrome.tabs.captureVisibleTab(tab.windowId, { + format: "jpeg", + quality, + }); + } + + // 点击元素 + async click(selector: string, options?: DomActionOptions): Promise { + const tabId = await this.resolveTabId(options?.tabId); + + if (options?.trusted) { + try { + return await withDebugger(tabId, (id) => cdpClick(id, selector)); + } catch (e) { + console.error("[AgentDom] CDP click failed, falling back to non-trusted mode", { + tabId, + selector, + error: e instanceof Error ? e.message : e, + }); + } + } + + return this.executeClick(tabId, selector); + } + + // 填写表单 + async fill(selector: string, value: string, options?: DomActionOptions): Promise { + const tabId = await this.resolveTabId(options?.tabId); + + if (options?.trusted) { + try { + return await withDebugger(tabId, (id) => cdpFill(id, selector, value)); + } catch (e) { + console.error("[AgentDom] CDP fill failed, falling back to non-trusted mode", { + tabId, + selector, + error: e instanceof Error ? e.message : e, + }); + } + } + + return this.executeFill(tabId, selector, value); + } + + // 滚动页面 + async scroll(direction: ScrollDirection, options?: ScrollOptions): Promise { + const tabId = await this.resolveTabId(options?.tabId); + const selector = options?.selector; + + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: executeScroll, + args: [direction, selector || null], + world: "ISOLATED", + }); + + if (!results || results.length === 0) { + throw new Error("Failed to scroll"); + } + + return results[0].result as ScrollResult; + } + + // 等待元素出现 + async waitFor(selector: string, options?: WaitForOptions): Promise { + const tabId = await this.resolveTabId(options?.tabId); + const timeout = options?.timeout ?? 10000; + const interval = 500; + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: checkElement, + args: [selector], + world: "ISOLATED", + }); + + if (results?.[0]?.result) { + return results[0].result as WaitForResult; + } + + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + return { found: false }; + } + + // 在页面中执行 JavaScript 代码(动态代码必须跑 MAIN world,ISOLATED 会被扩展 CSP 拦截 new Function) + async executeScript(code: string, options?: ExecuteScriptOptions): Promise<{ result: unknown; tabId: number }> { + const tabId = await this.resolveTabId(options?.tabId); + + const results = await chrome.scripting.executeScript({ + target: { tabId }, + func: (codeStr: string) => { + // 用 Function 构造器执行代码,支持 return 返回值 + const fn = new Function(codeStr); + return fn(); + }, + args: [code], + world: "MAIN", + }); + + if (!results || results.length === 0) { + throw new Error("Failed to execute script"); + } + + return { result: results[0].result, tabId }; + } + + // 启动页面监控(CDP:dialog 自动处理 + MutationObserver) + async startMonitor(tabId: number): Promise { + return cdpStartMonitor(tabId); + } + + // 停止监控并返回收集的结果 + async stopMonitor(tabId: number): Promise { + return cdpStopMonitor(tabId); + } + + // 查询当前 monitor 状态(不停止监控) + peekMonitor(tabId: number): MonitorStatus { + return cdpPeekMonitor(tabId); + } + + // 处理 GM API 请求路由 + async handleDomApi(request: DomApiRequest): Promise { + switch (request.action) { + case "listTabs": + return this.listTabs(); + case "navigate": + return this.navigate(request.url, request.options); + case "readPage": + return this.readPage(request.options); + case "screenshot": + return this.screenshot(request.options); + case "click": + return this.click(request.selector, request.options); + case "fill": + return this.fill(request.selector, request.value, request.options); + case "scroll": + return this.scroll(request.direction, request.options); + case "waitFor": + return this.waitFor(request.selector, request.options); + case "executeScript": + return this.executeScript(request.code, request.options); + case "startMonitor": + return this.startMonitor(request.tabId); + case "stopMonitor": + return this.stopMonitor(request.tabId); + case "peekMonitor": + return this.peekMonitor(request.tabId); + default: + throw new Error(`Unknown DOM action: ${(request as any).action}`); + } + } + + // ---- 辅助方法 ---- + + // 解析 tabId,未传则获取当前活动 tab + private async resolveTabId(tabId?: number): Promise { + if (tabId) { + const tab = await chrome.tabs.get(tabId); + // 校验目标 tab 的 URL 是否在黑名单中(同时兼顾原有受限页面检测) + assertDomUrlAllowed(tab.url || ""); + // 检测 tab 是否被 discard + if (tab.discarded) { + await chrome.tabs.reload(tabId); + await this.waitForPageLoad(tabId, 30000); + } + return tabId; + } + const tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + if (tabs.length === 0 || !tabs[0].id) { + throw new Error("No active tab found"); + } + // 校验当前活动 tab 的 URL 是否在黑名单中 + assertDomUrlAllowed(tabs[0].url || ""); + return tabs[0].id; + } + + // 等待页面加载完成 + private waitForPageLoad(tabId: number, timeout: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + reject(new Error("Page load timed out")); + }, timeout); + + const listener = (updatedTabId: number, changeInfo: { status?: string }) => { + if (updatedTabId === tabId && changeInfo.status === "complete") { + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }; + + // 先检查当前状态 + chrome.tabs.get(tabId).then((tab) => { + if (tab.status === "complete") { + clearTimeout(timer); + resolve(); + } else { + chrome.tabs.onUpdated.addListener(listener); + } + }); + }); + } + + // 默认模式点击 + private async executeClick(tabId: number, selector: string): Promise { + const tab = await chrome.tabs.get(tabId); + const originalUrl = tab.url || ""; + + // 监听新 tab 打开 + let newTabInfo: { tabId: number; url: string } | undefined; + const onCreated = (newTab: chrome.tabs.Tab) => { + if (newTab.openerTabId === tabId && newTab.id) { + newTabInfo = { tabId: newTab.id, url: newTab.pendingUrl || newTab.url || "" }; + } + }; + chrome.tabs.onCreated.addListener(onCreated); + + try { + // 执行点击 + await chrome.scripting.executeScript({ + target: { tabId }, + func: (sel: string) => { + const el = document.querySelector(sel); + if (!el) throw new Error(`Element not found: ${sel}`); + (el as HTMLElement).click(); + }, + args: [selector], + world: "ISOLATED", + }); + + // 等待页面稳定 + await new Promise((resolve) => setTimeout(resolve, 500)); + + // 收集结果 + return await this.collectActionResult(tabId, originalUrl, newTabInfo); + } finally { + chrome.tabs.onCreated.removeListener(onCreated); + } + } + + // 默认模式填写 + private async executeFill(tabId: number, selector: string, value: string): Promise { + const tab = await chrome.tabs.get(tabId); + const originalUrl = tab.url || ""; + + await chrome.scripting.executeScript({ + target: { tabId }, + func: (sel: string, val: string) => { + const el = document.querySelector(sel) as HTMLInputElement | HTMLTextAreaElement | null; + if (!el) throw new Error(`Element not found: ${sel}`); + el.focus(); + // 清空现有值 + el.value = ""; + // 设置新值 + el.value = val; + // 触发事件 + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + }, + args: [selector, value], + world: "ISOLATED", + }); + + return { + success: true, + url: originalUrl, + }; + } + + // 收集操作后的状态 + private async collectActionResult( + tabId: number, + originalUrl: string, + newTabInfo?: { tabId: number; url: string } + ): Promise { + let currentUrl = originalUrl; + try { + const tab = await chrome.tabs.get(tabId); + currentUrl = tab.url || originalUrl; + } catch { + // tab 可能已关闭 + } + + const navigated = currentUrl !== originalUrl; + + const result: ActionResult = { + success: true, + navigated, + url: currentUrl, + }; + + if (newTabInfo) { + result.newTab = newTabInfo; + } + + return result; + } +} + +// ---- 注入到页面中执行的函数 ---- + +// 读取页面 HTML(注入到页面执行) +function readPageContent(options: ReadPageInjectedOptions): PageContent { + const { selector, maxLength, removeTags } = options; + + const root = selector ? document.querySelector(selector) : document.documentElement; + if (!root) { + return { + title: document.title, + url: location.href, + html: `Element not found: ${selector}`, + }; + } + + // 克隆节点并移除指定标签 + const clone = root.cloneNode(true) as Element; + if (removeTags && removeTags.length > 0) { + for (const tag of removeTags) { + clone.querySelectorAll(tag).forEach((el) => el.remove()); + } + } + + const html = clone.outerHTML; + const result: PageContent = { + title: document.title, + url: location.href, + html, + }; + + if (html.length > maxLength) { + result.truncated = true; + result.totalLength = html.length; + result.html = html.slice(0, maxLength); + } + + return result; +} + +// 滚动操作(注入到页面执行) +function executeScroll(direction: string, selector: string | null): ScrollResult { + const target = selector ? document.querySelector(selector) : document.documentElement; + if (!target) throw new Error(`Element not found: ${selector}`); + + const el = selector ? (target as HTMLElement) : document.documentElement; + const scrollAmount = window.innerHeight * 0.8; + + switch (direction) { + case "up": + if (selector) { + el.scrollBy(0, -scrollAmount); + } else { + window.scrollBy(0, -scrollAmount); + } + break; + case "down": + if (selector) { + el.scrollBy(0, scrollAmount); + } else { + window.scrollBy(0, scrollAmount); + } + break; + case "top": + if (selector) { + el.scrollTop = 0; + } else { + window.scrollTo(0, 0); + } + break; + case "bottom": + if (selector) { + el.scrollTop = el.scrollHeight; + } else { + window.scrollTo(0, document.documentElement.scrollHeight); + } + break; + } + + const scrollEl = selector ? el : document.documentElement; + return { + scrollTop: scrollEl.scrollTop || window.scrollY, + scrollHeight: scrollEl.scrollHeight, + clientHeight: scrollEl.clientHeight || window.innerHeight, + atBottom: scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 10, + }; +} + +// 检查元素是否存在(注入到页面执行) +function checkElement(selector: string): WaitForResult | null { + const el = document.querySelector(selector); + if (!el) return null; + + const htmlEl = el as HTMLElement; + const style = window.getComputedStyle(el); + const visible = style.display !== "none" && style.visibility !== "hidden"; + + // 生成选择器 + const getSelector = (e: Element): string => { + if (e.id) return `#${e.id}`; + const tag = e.tagName.toLowerCase(); + const parent = e.parentElement; + if (!parent) return tag; + const siblings = Array.from(parent.children).filter((c) => c.tagName === e.tagName); + if (siblings.length === 1) return `${getSelector(parent)} > ${tag}`; + const index = siblings.indexOf(e) + 1; + return `${getSelector(parent)} > ${tag}:nth-of-type(${index})`; + }; + + return { + found: true, + element: { + selector: getSelector(el), + tag: el.tagName.toLowerCase(), + text: (htmlEl.textContent || "").trim().slice(0, 100), + role: el.getAttribute("role") || undefined, + type: el.getAttribute("type") || undefined, + visible, + }, + }; +} diff --git a/src/app/service/agent/service_worker/dom_cdp.test.ts b/src/app/service/agent/service_worker/dom_cdp.test.ts new file mode 100644 index 000000000..0170767ee --- /dev/null +++ b/src/app/service/agent/service_worker/dom_cdp.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; + +// mock chrome.debugger 和 chrome.tabs +const mockSendCommand = vi.fn(); +const mockAttach = vi.fn().mockResolvedValue(undefined); +const mockDetach = vi.fn().mockResolvedValue(undefined); +const mockTabsGet = vi.fn(); + +const savedChrome = globalThis.chrome; +vi.stubGlobal("chrome", { + debugger: { + attach: mockAttach, + detach: mockDetach, + sendCommand: mockSendCommand, + onEvent: { addListener: vi.fn(), removeListener: vi.fn() }, + }, + tabs: { get: mockTabsGet }, +}); + +import { cdpClick, withDebugger, cdpFill, cdpScreenshot } from "./dom_cdp"; + +afterAll(() => { + vi.stubGlobal("chrome", savedChrome); +}); + +// 构造 sendCommand 的响应映射 +function setupClickMocks(hitTestValue: string) { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockImplementation((_debuggee: unknown, method: string) => { + switch (method) { + case "DOM.getDocument": + return Promise.resolve({ root: { nodeId: 1 } }); + case "DOM.querySelector": + return Promise.resolve({ nodeId: 2 }); + case "DOM.scrollIntoViewIfNeeded": + return Promise.resolve({}); + case "DOM.getBoxModel": + return Promise.resolve({ + model: { content: [100, 100, 200, 100, 200, 200, 100, 200] }, + }); + case "Page.getLayoutMetrics": + return Promise.resolve({ visualViewport: { pageX: 0, pageY: 0 } }); + case "Runtime.evaluate": + return Promise.resolve({ result: { value: hitTestValue } }); + case "Input.dispatchMouseEvent": + return Promise.resolve({}); + default: + return Promise.resolve({}); + } + }); +} + +describe("agent_dom_cdp", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("模块可正常导入", () => { + expect(withDebugger).toBeDefined(); + expect(cdpClick).toBeDefined(); + expect(cdpFill).toBeDefined(); + 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("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"); + expect(mouseEvents).toHaveLength(0); + }); + + it("cdpClick 遮挡错误信息包含遮挡元素描述", async () => { + setupClickMocks("blocked_by:div#overlay.modal"); + await expect(cdpClick(999, "#btn")).rejects.toThrow(/blocked_by:div#overlay\.modal/); + }); + + it("cdpClick 在元素不存在时抛出错误", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockImplementation((_debuggee: unknown, method: string) => { + if (method === "DOM.getDocument") return Promise.resolve({ root: { nodeId: 1 } }); + if (method === "DOM.querySelector") return Promise.resolve({ nodeId: 0 }); + return Promise.resolve({}); + }); + await expect(cdpClick(999, "#nonexistent")).rejects.toThrow(/Element not found/); + }); +}); diff --git a/src/app/service/agent/service_worker/dom_cdp.ts b/src/app/service/agent/service_worker/dom_cdp.ts new file mode 100644 index 000000000..f05980868 --- /dev/null +++ b/src/app/service/agent/service_worker/dom_cdp.ts @@ -0,0 +1,383 @@ +// CDP(Chrome DevTools Protocol)操作封装 +// 通过 chrome.debugger API 实现真实用户输入模拟(isTrusted=true) +// 以及页面监控(dialog 自动处理 + DOM 变化捕获) + +import type { ActionResult, MonitorResult, ScreenshotOptions } from "@App/app/service/agent/core/types"; + +// 活跃的 monitor 会话,key 为 tabId(提前声明,withDebugger 需要检查) +type MonitorEventListener = (source: chrome.debugger.Debuggee, method: string, params?: any) => void; + +type CapturedNode = { + nodeId: number; + tag: string; + id?: string; + class?: string; + role?: string; +}; + +type MonitorSession = { + dialogs: Array<{ type: string; message: string }>; + capturedNodes: CapturedNode[]; // 从事件中直接提取的节点信息 + listener: MonitorEventListener; +}; + +const activeMonitors = new Map(); + +// 生命周期管理:attach → 执行 → detach +// 如果该 tabId 已有活跃的 monitor(已 attach),则复用连接,不做 attach/detach +export async function withDebugger(tabId: number, fn: (tabId: number) => Promise): Promise { + const hasMonitor = activeMonitors.has(tabId); + if (!hasMonitor) { + await chrome.debugger.attach({ tabId }, "1.3"); + } + try { + return await fn(tabId); + } finally { + if (!hasMonitor) { + try { + await chrome.debugger.detach({ tabId }); + } catch { + // tab 可能已经关闭 + } + } + } +} + +// 发送 CDP 命令的封装 +function sendCommand(tabId: number, method: string, params?: Record): Promise { + return chrome.debugger.sendCommand({ tabId }, method, params); +} + +// 通过 CDP 点击元素 +export async function cdpClick(tabId: number, selector: string): Promise { + const originalUrl = (await chrome.tabs.get(tabId)).url || ""; + + // 定位元素 + const doc = await sendCommand(tabId, "DOM.getDocument"); + const nodeId = await sendCommand(tabId, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector, + }); + if (!nodeId?.nodeId) { + throw new Error(`Element not found: ${selector}`); + } + + // 滚动到可见位置 + await sendCommand(tabId, "DOM.scrollIntoViewIfNeeded", { nodeId: nodeId.nodeId }); + + // 获取元素中心的页面坐标 + const boxModel = await sendCommand(tabId, "DOM.getBoxModel", { nodeId: nodeId.nodeId }); + if (!boxModel?.model) { + throw new Error(`Cannot get box model for: ${selector}`); + } + const content = boxModel.model.content; + // content 是 [x1,y1, x2,y2, x3,y3, x4,y4] 四个角的页面坐标 + const pageX = (content[0] + content[2] + content[4] + content[6]) / 4; + const pageY = (content[1] + content[3] + content[5] + content[7]) / 4; + + // 将页面坐标转为视口坐标(Input.dispatchMouseEvent 需要视口相对坐标) + const metrics = await sendCommand(tabId, "Page.getLayoutMetrics"); + const viewportX = pageX - (metrics.visualViewport?.pageX ?? 0); + const viewportY = pageY - (metrics.visualViewport?.pageY ?? 0); + + // 遮挡检测:检查该坐标处实际命中的元素是否是目标元素(或其子元素) + const selectorStr = JSON.stringify(selector); + const hitTest = await sendCommand(tabId, "Runtime.evaluate", { + expression: `(() => { + const el = document.elementFromPoint(${viewportX}, ${viewportY}); + const target = document.querySelector(${selectorStr}); + if (!el || !target) return 'not_found'; + if (target.contains(el) || el === target) return 'hit'; + return 'blocked_by:' + el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className ? '.' + String(el.className).split(' ').join('.') : ''); + })()`, + returnByValue: true, + }); + const hitValue = hitTest?.result?.value; + if (typeof hitValue === "string" && hitValue !== "hit") { + throw new Error( + `Click blocked: element at (${Math.round(viewportX)},${Math.round(viewportY)}) is ${hitValue}, not "${selector}"` + ); + } + + // 模拟鼠标点击 + await sendCommand(tabId, "Input.dispatchMouseEvent", { + type: "mousePressed", + x: viewportX, + y: viewportY, + button: "left", + clickCount: 1, + }); + await sendCommand(tabId, "Input.dispatchMouseEvent", { + type: "mouseReleased", + x: viewportX, + y: viewportY, + button: "left", + clickCount: 1, + }); + + // 等待页面稳定 + await new Promise((resolve) => setTimeout(resolve, 500)); + + // 收集结果 + const tab = await chrome.tabs.get(tabId); + const currentUrl = tab.url || ""; + return { + success: true, + navigated: currentUrl !== originalUrl, + url: currentUrl, + }; +} + +// 通过 CDP 填写表单 +export async function cdpFill(tabId: number, selector: string, value: string): Promise { + const originalUrl = (await chrome.tabs.get(tabId)).url || ""; + + // 定位元素 + const doc = await sendCommand(tabId, "DOM.getDocument"); + const nodeId = await sendCommand(tabId, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector, + }); + if (!nodeId?.nodeId) { + throw new Error(`Element not found: ${selector}`); + } + + // 聚焦元素 + await sendCommand(tabId, "DOM.focus", { nodeId: nodeId.nodeId }); + + // 全选并删除现有内容 + await sendCommand(tabId, "Input.dispatchKeyEvent", { + type: "keyDown", + key: "a", + code: "KeyA", + modifiers: 2, // Ctrl + }); + await sendCommand(tabId, "Input.dispatchKeyEvent", { + type: "keyUp", + key: "a", + code: "KeyA", + modifiers: 2, + }); + await sendCommand(tabId, "Input.dispatchKeyEvent", { + type: "keyDown", + key: "Delete", + code: "Delete", + }); + await sendCommand(tabId, "Input.dispatchKeyEvent", { + type: "keyUp", + key: "Delete", + code: "Delete", + }); + + // 逐字符输入 + for (const char of value) { + await sendCommand(tabId, "Input.dispatchKeyEvent", { + type: "keyDown", + key: char, + }); + await sendCommand(tabId, "Input.dispatchKeyEvent", { + type: "char", + text: char, + }); + await sendCommand(tabId, "Input.dispatchKeyEvent", { + type: "keyUp", + key: char, + }); + } + + // 等待页面稳定 + await new Promise((resolve) => setTimeout(resolve, 200)); + + return { + success: true, + url: originalUrl, + }; +} + +// 通过 CDP 截图 +export async function cdpScreenshot(tabId: number, options?: ScreenshotOptions): Promise { + const quality = options?.quality ?? 80; + const captureParams: Record = { + format: "jpeg", + quality, + captureBeyondViewport: options?.fullPage ?? false, + }; + + // 指定 selector 时,定位元素并裁剪截图区域 + if (options?.selector) { + const doc = await sendCommand(tabId, "DOM.getDocument"); + const nodeResult = await sendCommand(tabId, "DOM.querySelector", { + nodeId: doc.root.nodeId, + selector: options.selector, + }); + if (!nodeResult?.nodeId) { + throw new Error(`Screenshot target not found: ${options.selector}`); + } + // 滚动到可见区域 + await sendCommand(tabId, "DOM.scrollIntoViewIfNeeded", { nodeId: nodeResult.nodeId }); + const boxModel = await sendCommand(tabId, "DOM.getBoxModel", { nodeId: nodeResult.nodeId }); + if (!boxModel?.model) { + throw new Error(`Cannot get box model for: ${options.selector}`); + } + // content 是 [x1,y1, x2,y2, x3,y3, x4,y4] 四个角的页面坐标 + const content = boxModel.model.border; // 用 border box 包含边框 + const xs = [content[0], content[2], content[4], content[6]]; + const ys = [content[1], content[3], content[5], content[7]]; + const x = Math.min(...xs); + const y = Math.min(...ys); + const width = Math.max(...xs) - x; + const height = Math.max(...ys) - y; + captureParams.clip = { x, y, width, height, scale: 1 }; + // 区域截图需要 captureBeyondViewport 才能截到视口外的内容 + captureParams.captureBeyondViewport = true; + } + + const result = await sendCommand(tabId, "Page.captureScreenshot", captureParams); + return `data:image/jpeg;base64,${result.data}`; +} + +// ---- 页面监控(startMonitor / stopMonitor) ---- + +// 启动页面监控:attach debugger,纯 CDP 事件监听(dialog + DOM 变化),零注入 +export async function cdpStartMonitor(tabId: number): Promise { + // 如果已有 monitor,先停止 + if (activeMonitors.has(tabId)) { + await cdpStopMonitor(tabId); + } + + const dialogs: Array<{ type: string; message: string }> = []; + const capturedNodes: CapturedNode[] = []; + + // attach debugger + await chrome.debugger.attach({ tabId }, "1.3"); + await sendCommand(tabId, "Page.enable"); + await sendCommand(tabId, "DOM.enable"); + + // 获取 document root,触发 DOM 树追踪 + await sendCommand(tabId, "DOM.getDocument", { depth: 0 }); + + // 监听 CDP 事件 + const listener: MonitorEventListener = (source, method, params) => { + if (source.tabId !== tabId) return; + + // JS 弹框(alert/confirm/prompt) + if (method === "Page.javascriptDialogOpening") { + dialogs.push({ + type: String(params?.type || "alert"), + message: String(params?.message || ""), + }); + sendCommand(tabId, "Page.handleJavaScriptDialog", { accept: true }).catch(() => {}); + } + + // DOM 新增子节点:直接从事件的 node 对象提取属性 + if (method === "DOM.childNodeInserted") { + const node = params?.node; + if (node && node.nodeType === 1) { + // node.attributes 是 [name, value, name, value, ...] 扁平数组 + const attrs: Record = {}; + if (node.attributes) { + for (let i = 0; i < node.attributes.length; i += 2) { + attrs[node.attributes[i]] = node.attributes[i + 1]; + } + } + capturedNodes.push({ + nodeId: node.nodeId, + tag: (node.localName || node.nodeName || "").toLowerCase(), + id: attrs.id || undefined, + class: attrs.class || undefined, + role: attrs.role || undefined, + }); + } + } + }; + chrome.debugger.onEvent.addListener(listener); + + activeMonitors.set(tabId, { dialogs, capturedNodes, listener }); +} + +// 轻量查询当前 monitor 状态(不停止监控) +export function cdpPeekMonitor(tabId: number): { hasChanges: boolean; dialogCount: number; nodeCount: number } { + const monitor = activeMonitors.get(tabId); + if (!monitor) { + return { hasChanges: false, dialogCount: 0, nodeCount: 0 }; + } + const dialogCount = monitor.dialogs.length; + const nodeCount = monitor.capturedNodes.length; + return { hasChanges: dialogCount > 0 || nodeCount > 0, dialogCount, nodeCount }; +} + +// 从 outerHTML 中提取纯文本(去除所有标签) +function stripHtmlTags(html: string): string { + return html + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +// 停止监控:纯 CDP 解析新增节点 → 收集结果 → detach +export async function cdpStopMonitor(tabId: number): Promise { + const monitor = activeMonitors.get(tabId); + const result: MonitorResult = { + dialogs: monitor?.dialogs || [], + addedNodes: [], + }; + + if (monitor && monitor.capturedNodes.length > 0) { + // 去重(按 nodeId)并限制数量 + const seen = new Set(); + const uniqueNodes = monitor.capturedNodes + .filter((n) => { + if (seen.has(n.nodeId)) return false; + seen.add(n.nodeId); + return true; + }) + .slice(0, 50); + + for (const captured of uniqueNodes) { + try { + // 用 DOM.getBoxModel 检测可见性:不可见/未渲染的元素会抛异常 + await sendCommand(tabId, "DOM.getBoxModel", { nodeId: captured.nodeId }); + + // 用 DOM.getOuterHTML 获取内容,纯 CDP 无需注入 JS + const htmlResult = await sendCommand(tabId, "DOM.getOuterHTML", { nodeId: captured.nodeId }); + const outerHTML: string = htmlResult?.outerHTML || ""; + const text = stripHtmlTags(outerHTML).slice(0, 300); + if (!text) continue; + + result.addedNodes.push({ + tag: captured.tag, + id: captured.id, + class: captured.class, + role: captured.role, + text, + }); + } catch { + // 节点可能已被移除或不可见,跳过 + } + } + } + + // 清理 + if (monitor) { + chrome.debugger.onEvent.removeListener(monitor.listener); + activeMonitors.delete(tabId); + } + + try { + await sendCommand(tabId, "DOM.disable"); + } catch { + /* 忽略 */ + } + try { + await sendCommand(tabId, "Page.disable"); + } catch { + /* 忽略 */ + } + try { + await chrome.debugger.detach({ tabId }); + } catch { + /* 忽略 */ + } + + return result; +} diff --git a/src/app/service/agent/service_worker/dom_policy.ts b/src/app/service/agent/service_worker/dom_policy.ts new file mode 100644 index 000000000..2b5dc0974 --- /dev/null +++ b/src/app/service/agent/service_worker/dom_policy.ts @@ -0,0 +1,45 @@ +// agent_dom_policy.ts — Agent DOM 操作权限守卫 +// 防止 Agent 操作浏览器内部页面或其他受限 URL + +/** + * 敏感 URL 前缀黑名单。 + * 初始只包含浏览器内部协议,为后续扩展(如金融域名)预留位置。 + * + * 格式:每个条目为字符串前缀,会对 URL 做 startsWith 匹配(大小写不敏感)。 + */ +export const SENSITIVE_HOST_PATTERNS: readonly string[] = [ + // 浏览器内部页面 + "chrome://", + "chrome-extension://", + "edge://", + "about:", + "devtools://", + "view-source:", + // 预留位置:如需添加敏感金融/支付域名,可在此追加 +]; + +/** + * 断言给定 URL 允许被 Agent DOM 操作访问。 + * 不合规则时抛出 Error,由调用方统一向 Agent 报错。 + * + * @param url 要校验的目标 URL(navigate 传入的字符串,或 tab.url) + * @throws {Error} 若 URL 匹配黑名单或为空 + */ +export function assertDomUrlAllowed(url: string): void { + // 空字符串拒绝(无法判断目标,安全起见拒绝) + if (!url) { + throw new Error("Agent DOM operation not allowed for URL: (empty)"); + } + + // about:blank 是用户刚打开的新标签页,允许 + if (url === "about:blank") { + return; + } + + const lower = url.toLowerCase(); + for (const pattern of SENSITIVE_HOST_PATTERNS) { + if (lower.startsWith(pattern.toLowerCase())) { + throw new Error(`Agent DOM operation not allowed for URL: ${url}`); + } + } +} diff --git a/src/app/service/agent/service_worker/llm.test.ts b/src/app/service/agent/service_worker/llm.test.ts new file mode 100644 index 000000000..82f7993f3 --- /dev/null +++ b/src/app/service/agent/service_worker/llm.test.ts @@ -0,0 +1,619 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createTestService, makeSSEResponse, makeTextResponse } from "./test-helpers"; + +// ---- callLLM 相关测试(通过 callLLMWithToolLoop 间接测试) ---- + +describe("callLLM 流式响应解析", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + // 辅助:创建 Anthropic SSE Response + function makeAnthropicSSEResponse(events: Array<{ event: string; data: any }>): Response { + const encoder = new TextEncoder(); + const chunks = events.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`); + let i = 0; + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + if (i >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: encoder.encode(chunks[i++]) }; + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + }), + }, + text: async () => "", + } as unknown as Response; + } + + function createMockSender() { + const sentMessages: any[] = []; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + return { sender, sentMessages }; + } + + const BASE_CONV = { + id: "conv-1", + title: "Test", + modelId: "test-openai", + createtime: Date.now(), + updatetime: Date.now(), + }; + + it("正常文本响应:OpenAI SSE → sendEvent 收到 content_delta + done", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + fetchSpy.mockResolvedValueOnce( + makeSSEResponse([ + `data: {"choices":[{"delta":{"content":"你好"}}]}\n\n`, + `data: {"choices":[{"delta":{"content":"世界"}}]}\n\n`, + `data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`, + ]) + ); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "hi" }, sender); + + const events = sentMessages.map((m) => m.data); + const contentDeltas = events.filter((e: any) => e.type === "content_delta"); + const doneEvents = events.filter((e: any) => e.type === "done"); + + expect(contentDeltas.length).toBeGreaterThanOrEqual(1); + expect(doneEvents).toHaveLength(1); + expect(doneEvents[0].usage).toBeDefined(); + expect(doneEvents[0].usage.inputTokens).toBe(10); + expect(doneEvents[0].usage.outputTokens).toBe(5); + }); + + it("正常文本响应(Anthropic provider):验证 buildAnthropicRequest + parseAnthropicStream", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + // 设置 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", + }, + ]), + 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(undefined); + }), + getDefaultModelId: vi.fn().mockResolvedValue("test-anthropic"), + saveModel: vi.fn(), + removeModel: vi.fn(), + setDefaultModelId: vi.fn(), + }; + (service as any).modelRepo = anthropicModelRepo; + + const conv = { ...BASE_CONV, modelId: "test-anthropic" }; + mockRepo.listConversations.mockResolvedValue([conv]); + mockRepo.getMessages.mockResolvedValue([]); + + fetchSpy.mockResolvedValueOnce( + makeAnthropicSSEResponse([ + { event: "message_start", data: { message: { usage: { input_tokens: 15 } } } }, + { event: "content_block_start", data: { content_block: { type: "text", text: "" } } }, + { event: "content_block_delta", data: { delta: { type: "text_delta", text: "你好世界" } } }, + { event: "message_delta", data: { usage: { output_tokens: 8 } } }, + ]) + ); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "hi" }, sender); + + // 验证请求使用了 Anthropic 格式 + const reqInit = fetchSpy.mock.calls[0][1]; + expect(reqInit.headers["x-api-key"]).toBe("sk-test"); + expect(fetchSpy.mock.calls[0][0]).toContain("/v1/messages"); + + const events = sentMessages.map((m) => m.data); + const contentDeltas = events.filter((e: any) => e.type === "content_delta"); + const doneEvents = events.filter((e: any) => e.type === "done"); + expect(contentDeltas).toHaveLength(1); + expect(contentDeltas[0].delta).toBe("你好世界"); + expect(doneEvents).toHaveLength(1); + expect(doneEvents[0].usage.inputTokens).toBe(15); + expect(doneEvents[0].usage.outputTokens).toBe(8); + }); + + it("API 错误响应(HTTP 401):4xx 客户端错误不重试,立即收到 error + errorCode=auth", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // 401 不重试,只需提供 1 次 mock + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => "401 Unauthorized", + } as unknown as Response); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "hi" }, sender); + + // 仅调用 1 次 fetch,不重试 + expect(fetchSpy).toHaveBeenCalledTimes(1); + + const events = sentMessages.map((m) => m.data); + const errorEvents = events.filter((e: any) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0].errorCode).toBe("auth"); + }); + + it("API 错误响应(HTTP 429):应进入重试循环,第二次成功", async () => { + vi.useFakeTimers(); + + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // 第一次 429,第二次成功 + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 429, + text: async () => "Too Many Requests", + } as unknown as Response); + fetchSpy.mockResolvedValueOnce( + makeSSEResponse([ + `data: {"choices":[{"delta":{"content":"重试成功"}}]}\n\n`, + `data: {"usage":{"prompt_tokens":5,"completion_tokens":2}}\n\n`, + ]) + ); + + const chatPromise = (service as any).handleConversationChat({ conversationId: "conv-1", message: "hi" }, sender); + + // 推进定时器跳过第一次重试延迟(10s) + await vi.advanceTimersByTimeAsync(10_000); + + await chatPromise; + + // fetch 应被调用 2 次(429 + 成功) + expect(fetchSpy).toHaveBeenCalledTimes(2); + + const events = sentMessages.map((m) => m.data); + // 应有 1 次 retry 通知 + const retryEvents = events.filter((e: any) => e.type === "retry"); + expect(retryEvents).toHaveLength(1); + expect(retryEvents[0].attempt).toBe(1); + // 最终应成功完成 + const doneEvents = events.filter((e: any) => e.type === "done"); + expect(doneEvents).toHaveLength(1); + + vi.useRealTimers(); + }); + + it("API 错误响应(HTTP 500 后重试成功):withRetry 生效", async () => { + vi.useFakeTimers(); + + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // 第一次 500 错误 + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => "Internal Server Error", + } as unknown as Response); + + // 第二次成功 + fetchSpy.mockResolvedValueOnce( + makeSSEResponse([ + `data: {"choices":[{"delta":{"content":"恢复了"}}]}\n\n`, + `data: {"usage":{"prompt_tokens":10,"completion_tokens":3}}\n\n`, + ]) + ); + + 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); + + 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 () => { + vi.useFakeTimers(); + + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // callLLM 内部会重试 5 次,需提供足够多的 mock + const makeNoBody = () => ({ ok: true, status: 200, body: null, text: async () => "" }) as unknown as Response; + for (let i = 0; i < 6; i++) fetchSpy.mockResolvedValueOnce(makeNoBody()); + + const chatPromise = (service as any).handleConversationChat({ conversationId: "conv-1", message: "hi" }, sender); + + // 推进定时器跳过 callLLM 内部重试延迟 + await vi.advanceTimersByTimeAsync(100_000); + + await chatPromise; + + const events = sentMessages.map((m) => m.data); + const errorEvents = events.filter((e: any) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0].message).toContain("No response body"); + + vi.useRealTimers(); + }); + + it("AbortSignal 中止:disconnect 后不再发送消息", async () => { + const { service, mockRepo } = createTestService(); + const sentMessages: any[] = []; + let disconnectCb: (() => void) | null = null; + + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn(), + onDisconnect: vi.fn().mockImplementation((cb: () => void) => { + disconnectCb = cb; + }), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // fetch 抛 AbortError(模拟 signal 取消 fetch) + fetchSpy.mockImplementation((_url: string, _init: RequestInit) => { + // 在 fetch 调用时立即触发 disconnect + if (disconnectCb) { + disconnectCb(); + disconnectCb = null; + } + // 模拟 abort 导致 fetch reject + return Promise.reject(new DOMException("The operation was aborted", "AbortError")); + }); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "hi" }, sender); + + // abort 后 handleConversationChat 检测到 signal.aborted,静默返回 + const events = sentMessages.map((m) => m.data); + // 不应有 error 事件(abort 不算 error) + const errorEvents = events.filter((e: any) => e.type === "error"); + expect(errorEvents).toHaveLength(0); + // 不应有 done 事件 + const doneEvents = events.filter((e: any) => e.type === "done"); + expect(doneEvents).toHaveLength(0); + }); +}); + +// ---- callLLMWithToolLoop 场景补充 ---- + +describe("callLLMWithToolLoop 工具调用循环", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + 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: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`); + return makeSSEResponse(chunks); + } + + function createMockSender() { + const sentMessages: any[] = []; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + return { sender, sentMessages }; + } + + const BASE_CONV = { + id: "conv-1", + title: "Test", + modelId: "test-openai", + createtime: Date.now(), + updatetime: Date.now(), + }; + + it("工具调用单轮:tool_call → 执行 → 文本完成", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + // 注册一个内置工具 + const registry = (service as any).toolRegistry; + registry.registerBuiltin( + { name: "echo", description: "Echo", parameters: { type: "object", properties: { msg: { type: "string" } } } }, + { execute: async (args: Record) => `echo: ${args.msg}` } + ); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // 第一次:返回 tool_call + fetchSpy.mockResolvedValueOnce( + makeToolCallResponse([{ id: "call_1", name: "echo", arguments: '{"msg":"hello"}' }]) + ); + // 第二次:纯文本 + fetchSpy.mockResolvedValueOnce(makeTextResponse("done")); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "test" }, sender); + + const events = sentMessages.map((m) => m.data); + // 应有 tool_call_start, tool_call_complete, new_message, done + 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(events.some((e: any) => e.type === "new_message")).toBe(true); + expect(events.some((e: any) => e.type === "done")).toBe(true); + + // assistant 消息应持久化(tool_calls 和最终文本各一条) + const appendCalls = mockRepo.appendMessage.mock.calls; + const assistantCalls = appendCalls.filter((c: any) => c[0].role === "assistant"); + expect(assistantCalls).toHaveLength(2); // tool_call + final text + + // fetch 应调用 2 次 + expect(fetchSpy).toHaveBeenCalledTimes(2); + + registry.unregisterBuiltin("echo"); + }); + + it("工具调用多轮(3 轮):连续 tool_call 后文本", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + const registry = (service as any).toolRegistry; + let callCount = 0; + registry.registerBuiltin( + { name: "counter", description: "Count", parameters: { type: "object", properties: {} } }, + { + execute: async () => { + callCount++; + return `count=${callCount}`; + }, + } + ); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // 3 轮 tool_call + fetchSpy.mockResolvedValueOnce(makeToolCallResponse([{ id: "c1", name: "counter", arguments: "{}" }])); + fetchSpy.mockResolvedValueOnce(makeToolCallResponse([{ id: "c2", name: "counter", arguments: "{}" }])); + fetchSpy.mockResolvedValueOnce(makeToolCallResponse([{ id: "c3", name: "counter", arguments: "{}" }])); + // 最终文本 + fetchSpy.mockResolvedValueOnce(makeTextResponse("done")); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "test" }, sender); + + expect(fetchSpy).toHaveBeenCalledTimes(4); + expect(callCount).toBe(3); + + const events = sentMessages.map((m) => m.data); + const doneEvents = events.filter((e: any) => e.type === "done"); + expect(doneEvents).toHaveLength(1); + // done 事件 usage 应累计 4 轮 + expect(doneEvents[0].usage.inputTokens).toBe(40); // 10 * 4 + expect(doneEvents[0].usage.outputTokens).toBe(20); // 5 * 4 + + registry.unregisterBuiltin("counter"); + }); + + it("超过 maxIterations:sendEvent 收到 max_iterations 错误", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + const registry = (service as any).toolRegistry; + registry.registerBuiltin( + { name: "loop", description: "Loop", parameters: { type: "object", properties: {} } }, + { execute: async () => "ok" } + ); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // maxIterations=1 但 LLM 一直返回 tool_call + fetchSpy.mockResolvedValueOnce(makeToolCallResponse([{ id: "c1", name: "loop", arguments: "{}" }])); + + await (service as any).handleConversationChat( + { conversationId: "conv-1", message: "test", maxIterations: 1 }, + sender + ); + + const events = sentMessages.map((m) => m.data); + const errorEvents = events.filter((e: any) => e.type === "error"); + expect(errorEvents).toHaveLength(1); + expect(errorEvents[0].message).toContain("maximum iterations"); + expect(errorEvents[0].errorCode).toBe("max_iterations"); + + // fetch 只调用 1 次(maxIterations=1) + expect(fetchSpy).toHaveBeenCalledTimes(1); + + registry.unregisterBuiltin("loop"); + }); + + it("工具执行后附件回写:toolCalls 被更新", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + const registry = (service as any).toolRegistry; + // 注册返回带附件结果的工具 + registry.registerBuiltin( + { name: "screenshot", description: "Screenshot", parameters: { type: "object", properties: {} } }, + { + execute: async () => ({ + content: "Screenshot taken", + attachments: [{ type: "image", name: "shot.png", mimeType: "image/png", data: "base64data" }], + }), + } + ); + // 注入 mock chatRepo 到 registry 用于保存附件 + registry.setChatRepo({ + saveAttachment: vi.fn().mockResolvedValue(1024), + }); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + // appendMessage 后 getMessages 返回含 toolCalls 的 assistant 消息 + const storedMessages: any[] = []; + mockRepo.appendMessage.mockImplementation(async (msg: any) => { + storedMessages.push(msg); + }); + mockRepo.getMessages.mockImplementation(async () => [...storedMessages]); + + // 第一次:tool_call + fetchSpy.mockResolvedValueOnce(makeToolCallResponse([{ id: "sc1", name: "screenshot", arguments: "{}" }])); + // 第二次:文本 + fetchSpy.mockResolvedValueOnce(makeTextResponse("done")); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "截图" }, sender); + + const events = sentMessages.map((m) => m.data); + const completeEvent = events.find((e: any) => e.type === "tool_call_complete"); + expect(completeEvent).toBeDefined(); + expect(completeEvent.result).toBe("Screenshot taken"); + expect(completeEvent.attachments).toHaveLength(1); + expect(completeEvent.attachments[0].type).toBe("image"); + + registry.unregisterBuiltin("screenshot"); + }); + + it("同一轮返回多个 tool_call:两个工具都被执行", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + const registry = (service as any).toolRegistry; + const executedTools: string[] = []; + registry.registerBuiltin( + { name: "tool_a", description: "Tool A", parameters: { type: "object", properties: { x: { type: "string" } } } }, + { + execute: async (args: Record) => { + executedTools.push("tool_a"); + return `a: ${args.x}`; + }, + } + ); + registry.registerBuiltin( + { name: "tool_b", description: "Tool B", parameters: { type: "object", properties: { y: { type: "string" } } } }, + { + execute: async (args: Record) => { + executedTools.push("tool_b"); + return `b: ${args.y}`; + }, + } + ); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // 第一次:同时返回两个 tool_call + fetchSpy.mockResolvedValueOnce( + makeToolCallResponse([ + { id: "call_a", name: "tool_a", arguments: '{"x":"hello"}' }, + { id: "call_b", name: "tool_b", arguments: '{"y":"world"}' }, + ]) + ); + // 第二次:纯文本 + fetchSpy.mockResolvedValueOnce(makeTextResponse("完成")); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "test" }, sender); + + // 两个工具都应被执行 + expect(executedTools).toEqual(["tool_a", "tool_b"]); + + const events = sentMessages.map((m) => m.data); + + // 应有两个 tool_call_start + const startEvents = events.filter((e: any) => e.type === "tool_call_start"); + expect(startEvents).toHaveLength(2); + expect(startEvents[0].toolCall.name).toBe("tool_a"); + expect(startEvents[1].toolCall.name).toBe("tool_b"); + + // 应有两个 tool_call_complete + const completeEvents = events.filter((e: any) => e.type === "tool_call_complete"); + expect(completeEvents).toHaveLength(2); + expect(completeEvents.find((e: any) => e.id === "call_a").result).toBe("a: hello"); + expect(completeEvents.find((e: any) => e.id === "call_b").result).toBe("b: world"); + + // 持久化的 assistant 消息应包含两个 toolCalls + const assistantMsgs = mockRepo.appendMessage.mock.calls + .map((c: any) => c[0]) + .filter((m: any) => m.role === "assistant" && m.toolCalls); + expect(assistantMsgs).toHaveLength(1); + expect(assistantMsgs[0].toolCalls).toHaveLength(2); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + + registry.unregisterBuiltin("tool_a"); + registry.unregisterBuiltin("tool_b"); + }); +}); diff --git a/src/app/service/agent/service_worker/llm_client.ts b/src/app/service/agent/service_worker/llm_client.ts new file mode 100644 index 000000000..5c58448c2 --- /dev/null +++ b/src/app/service/agent/service_worker/llm_client.ts @@ -0,0 +1,253 @@ +import { agentChatRepo } from "@App/app/repo/agent_chat"; +import type { + AgentModelConfig, + ChatRequest, + ChatStreamEvent, + ContentBlock, + ToolCall, + ToolDefinition, +} from "@App/app/service/agent/core/types"; +import { providerRegistry } from "@App/app/service/agent/core/providers"; +import { resolveAttachments } from "@App/app/service/agent/core/attachment_resolver"; + +export interface LLMCallResult { + content: string; + thinking?: string; + toolCalls?: ToolCall[]; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + contentBlocks?: ContentBlock[]; +} + +export class LLMClient { + /** + * 调用 LLM 并收集完整响应(内部处理流式、重试与图片保存) + */ + async callLLM( + model: AgentModelConfig, + params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ): Promise { + const chatRequest: ChatRequest = { + conversationId: "", + modelId: model.id, + messages: params.messages, + tools: params.tools, + cache: params.cache, + }; + + // 预解析消息中 ContentBlock 引用的 attachmentId → base64 + const attachmentResolver = await resolveAttachments(params.messages, model, (id) => + agentChatRepo.getAttachment(id) + ); + + // zhipu 暂无独立实现,映射到 openai provider;独立实现后可移除此映射 + const providerName = model.provider === "zhipu" ? "openai" : model.provider; + const provider = providerRegistry.get(providerName); + if (!provider) { + throw new Error(`Unsupported LLM provider: ${model.provider}`); + } + // zhipu 需要设置默认 apiBaseUrl + const resolvedModel = + model.provider === "zhipu" + ? { ...model, apiBaseUrl: model.apiBaseUrl || "https://open.bigmodel.cn/api/paas/v4" } + : model; + const { url, init } = await provider.buildRequest({ + model: resolvedModel, + request: chatRequest, + resolver: attachmentResolver, + }); + + // 带重试的 LLM 调用,最多重试 5 次,间隔递增:10s, 10s, 20s, 20s, 30s + const RETRY_DELAYS = [10_000, 10_000, 20_000, 20_000, 30_000]; + const MAX_RETRIES = RETRY_DELAYS.length; + let response!: Response; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + response = await fetch(url, { ...init, 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)}`; + } + throw new Error(errorMessage); + } + + if (!response.body) { + throw new Error("No response body"); + } + // 请求成功,跳出重试循环 + break; + } catch (e: any) { + // 用户取消时直接抛出,不重试 + if (signal.aborted) throw e; + // 4xx 客户端错误(除 408/425/429 外)不重试,立即抛出 + const m = (e.message || "").match(/API error:\s*(\d{3})/); + if (m) { + const code = Number(m[1]); + if (code >= 400 && code < 500 && code !== 408 && code !== 425 && code !== 429) { + throw e; + } + } + // 已用完所有重试次数 + if (attempt >= MAX_RETRIES) throw e; + // 向 UI 发送重试通知(含延迟时间,用于倒计时显示) + const delayMs = RETRY_DELAYS[attempt]; + sendEvent({ + type: "retry", + attempt: attempt + 1, + maxRetries: MAX_RETRIES, + error: e.message || "Unknown error", + delayMs, + }); + // 等待后重试,等待期间可被 abort 取消;resolve 时移除监听器避免泄漏 + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(new Error("Aborted during retry wait")); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, delayMs); + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + } + + const reader = response.body!.getReader(); + const parseStream = provider.parseStream.bind(provider); + + // 收集响应 + let content = ""; + let thinking = ""; + const toolCalls: ToolCall[] = []; + let currentToolCall: ToolCall | null = null; + let usage: + | { inputTokens: number; outputTokens: number; cacheCreationInputTokens?: number; cacheReadInputTokens?: number } + | undefined; + // 收集带 data 的图片 block(模型生成的图片),stream 结束后统一保存到 OPFS + const pendingImageSaves: Array<{ block: ContentBlock & { type: "image" }; data: string }> = []; + + return new Promise((resolve, reject) => { + const onEvent = (event: ChatStreamEvent) => { + // 只转发流式内容事件,done 和 error 由 callLLMWithToolLoop 统一管理 + // 避免在 tool calling 循环中提前发送 done 导致客户端过早 resolve + // 带 data 的 content_block_complete 暂不转发,等 OPFS 保存后再发 + if (event.type !== "done" && event.type !== "error") { + if (event.type === "content_block_complete" && event.data) { + // 暂存,稍后保存到 OPFS 后再转发 + pendingImageSaves.push({ block: event.block as ContentBlock & { type: "image" }, data: event.data }); + } else { + sendEvent(event); + } + } + + switch (event.type) { + case "content_delta": + content += event.delta; + break; + case "thinking_delta": + thinking += event.delta; + break; + case "tool_call_start": + // 如果已有一个正在收集的 tool call,先保存它(多个 tool_use 并行返回时) + if (currentToolCall) { + toolCalls.push(currentToolCall); + } + currentToolCall = { ...event.toolCall, arguments: event.toolCall.arguments || "" }; + break; + case "tool_call_delta": + if (currentToolCall) { + currentToolCall.arguments += event.delta; + } + break; + case "done": { + // 保存当前的 tool call + if (currentToolCall) { + toolCalls.push(currentToolCall); + currentToolCall = null; + } + if (event.usage) { + usage = event.usage; + } + + // 保存模型生成的图片到 OPFS,然后转发事件 + const finalize = async () => { + const savedBlocks: ContentBlock[] = []; + for (const pending of pendingImageSaves) { + try { + await agentChatRepo.saveAttachment(pending.block.attachmentId, pending.data); + savedBlocks.push(pending.block); + // 转发不含 data 的 content_block_complete 事件给 UI + sendEvent({ type: "content_block_complete", block: pending.block }); + } catch { + // 保存失败忽略 + } + } + + // 提取文本中的 markdown 内联 base64 图片(某些 API 以 ![alt](data:image/...;base64,...) 形式返回图片) + const imgRegex = /!\[([^\]]*)\]\((data:image\/([^;]+);base64,[A-Za-z0-9+/=\s]+)\)/g; + let match; + let cleanedContent = content; + while ((match = imgRegex.exec(content)) !== null) { + const [fullMatch, alt, dataUrl, subtype] = match; + const mimeType = `image/${subtype}`; + const ext = subtype || "png"; + const blockId = `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`; + try { + await agentChatRepo.saveAttachment(blockId, dataUrl); + const block: ContentBlock = { + type: "image", + attachmentId: blockId, + mimeType, + name: alt || "generated-image", + }; + savedBlocks.push(block); + sendEvent({ type: "content_block_complete", block }); + cleanedContent = cleanedContent.replace(fullMatch, ""); + } catch { + // 保存失败保留原始 markdown + } + } + // 清理提取图片后的多余空行 + if (cleanedContent !== content) { + content = cleanedContent.replace(/\n{3,}/g, "\n\n").trim(); + } + + return savedBlocks.length > 0 ? savedBlocks : undefined; + }; + + finalize() + .then((contentBlocks) => { + resolve({ + content, + thinking: thinking || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + usage, + contentBlocks, + }); + }) + .catch(reject); + break; + } + case "error": + reject(new Error(event.message)); + break; + } + }; + + parseStream(reader, onEvent, signal).catch(reject); + }); + } +} diff --git a/src/app/service/agent/service_worker/mcp.test.ts b/src/app/service/agent/service_worker/mcp.test.ts new file mode 100644 index 000000000..ade68726d --- /dev/null +++ b/src/app/service/agent/service_worker/mcp.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MCPService } from "./mcp"; +import { ToolRegistry } from "@App/app/service/agent/core/tool_registry"; +import type { MCPClientFactory } from "./mcp"; +import type { MCPServerRepo } from "@App/app/repo/mcp_server_repo"; + +// 创建 mock MCPServerRepo +function createMockRepo() { + const servers = new Map(); + return { + listServers: vi.fn(async () => Array.from(servers.values())), + getServer: vi.fn(async (id: string) => servers.get(id)), + saveServer: vi.fn(async (config: any) => { + servers.set(config.id, config); + }), + removeServer: vi.fn(async (id: string) => { + servers.delete(id); + }), + } as unknown as MCPServerRepo; +} + +// Mock MCPClient 工厂 +function createMockClientFactory(): MCPClientFactory { + return () => + ({ + async initialize() {}, + async listTools() { + return [ + { + serverId: "test-server", + name: "search", + description: "Search the web", + inputSchema: { type: "object", properties: { query: { type: "string" } } }, + }, + ]; + }, + async listResources() { + return [{ serverId: "test-server", uri: "file:///test.md", name: "test", mimeType: "text/markdown" }]; + }, + async listPrompts() { + return [{ serverId: "test-server", name: "summarize", description: "Summarize text" }]; + }, + async callTool() { + return "tool result"; + }, + async readResource() { + return { contents: [{ uri: "file:///test.md", text: "# Test" }] }; + }, + async getPrompt() { + return [{ role: "user", content: { type: "text", text: "Hello" } }]; + }, + close() {}, + isInitialized() { + return true; + }, + }) as any; +} + +describe("MCPService", () => { + let toolRegistry: ToolRegistry; + let service: MCPService; + + beforeEach(() => { + toolRegistry = new ToolRegistry(); + service = new MCPService(toolRegistry, { + clientFactory: createMockClientFactory(), + repo: createMockRepo(), + }); + }); + + describe("handleMCPApi - addServer", () => { + it("应添加服务器", async () => { + const result = (await service.handleMCPApi({ + action: "addServer", + config: { name: "Test", url: "https://mcp.test.com", enabled: false }, + scriptUuid: "test", + })) as any; + + expect(typeof result.id).toBe("string"); + expect(result.id.length).toBeGreaterThan(0); + expect(result.name).toBe("Test"); + expect(result.url).toBe("https://mcp.test.com"); + }); + }); + + describe("handleMCPApi - listServers", () => { + it("应列出所有服务器", async () => { + await service.handleMCPApi({ + action: "addServer", + config: { name: "Test", url: "https://mcp.test.com", enabled: false }, + scriptUuid: "test", + }); + + const result = (await service.handleMCPApi({ + action: "listServers", + scriptUuid: "test", + })) as any[]; + + expect(result.length).toBe(1); + }); + }); + + describe("handleMCPApi - removeServer", () => { + it("应删除服务器", async () => { + const server = (await service.handleMCPApi({ + action: "addServer", + config: { name: "Test", url: "https://mcp.test.com", enabled: false }, + scriptUuid: "test", + })) as any; + + const result = await service.handleMCPApi({ + action: "removeServer", + id: server.id, + scriptUuid: "test", + }); + + expect(result).toBe(true); + }); + }); + + describe("connectServer / disconnectServer", () => { + it("连接后应将工具注册到 ToolRegistry", async () => { + const server = (await service.handleMCPApi({ + action: "addServer", + config: { name: "TestSrv", url: "https://mcp.test.com", enabled: false }, + scriptUuid: "test", + })) as any; + + await service.connectServer(server.id); + + const defs = toolRegistry.getDefinitions(); + expect(defs.length).toBe(1); + expect(defs[0].name).toContain("search"); + }); + + it("断开后应注销工具", async () => { + const server = (await service.handleMCPApi({ + action: "addServer", + config: { name: "TestSrv", url: "https://mcp.test.com", enabled: false }, + scriptUuid: "test", + })) as any; + + await service.connectServer(server.id); + expect(toolRegistry.getDefinitions().length).toBe(1); + + await service.disconnectServer(server.id); + expect(toolRegistry.getDefinitions().length).toBe(0); + }); + }); + + describe("handleMCPApi - listTools", () => { + it("应通过懒连接获取工具列表", async () => { + const server = (await service.handleMCPApi({ + action: "addServer", + config: { name: "Test", url: "https://mcp.test.com", enabled: false }, + scriptUuid: "test", + })) as any; + + const tools = (await service.handleMCPApi({ + action: "listTools", + serverId: server.id, + scriptUuid: "test", + })) as any[]; + + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe("search"); + }); + }); + + describe("handleMCPApi - testConnection", () => { + it("应返回工具、资源、提示词数量", async () => { + const server = (await service.handleMCPApi({ + action: "addServer", + config: { name: "Test", url: "https://mcp.test.com", enabled: false }, + scriptUuid: "test", + })) as any; + + const result = (await service.handleMCPApi({ + action: "testConnection", + id: server.id, + scriptUuid: "test", + })) as any; + + expect(result.tools).toBe(1); + expect(result.resources).toBe(1); + expect(result.prompts).toBe(1); + }); + }); + + describe("handleMCPApi - unknown action", () => { + it("应抛出错误", async () => { + await expect(service.handleMCPApi({ action: "unknown" as any, scriptUuid: "test" })).rejects.toThrow( + "Unknown MCP action" + ); + }); + }); +}); diff --git a/src/app/service/agent/service_worker/mcp.ts b/src/app/service/agent/service_worker/mcp.ts new file mode 100644 index 000000000..79185995d --- /dev/null +++ b/src/app/service/agent/service_worker/mcp.ts @@ -0,0 +1,243 @@ +import type { MCPApiRequest, MCPServerConfig, MCPTool, ToolDefinition } from "@App/app/service/agent/core/types"; +import { MCPClient } from "@App/app/service/agent/core/mcp_client"; +import { MCPToolExecutor } from "@App/app/service/agent/core/mcp_tool_executor"; +import { MCPServerRepo } from "@App/app/repo/mcp_server_repo"; +import type { ToolRegistry } from "@App/app/service/agent/core/tool_registry"; +import { uuidv4 } from "@App/pkg/utils/uuid"; + +// 将服务器名和工具名合成为全局唯一的工具名 +function mcpToolName(serverName: string, toolName: string): string { + // 使用小写字母和下划线,避免特殊字符 + const safeName = serverName.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase(); + return `mcp_${safeName}_${toolName}`; +} + +// MCPClient 工厂函数类型 +export type MCPClientFactory = (config: MCPServerConfig) => MCPClient; + +// 默认工厂:直接创建 MCPClient +const defaultClientFactory: MCPClientFactory = (config) => new MCPClient(config); + +// MCPService 管理 MCP 服务器连接池和工具注册 +export class MCPService { + private repo: MCPServerRepo; + private clients = new Map(); + // 记录每个服务器注册的工具名,便于注销 + private registeredTools = new Map(); + private createClient: MCPClientFactory; + + constructor( + private toolRegistry: ToolRegistry, + options?: { clientFactory?: MCPClientFactory; repo?: MCPServerRepo } + ) { + this.createClient = options?.clientFactory || defaultClientFactory; + this.repo = options?.repo || new MCPServerRepo(); + } + + // 加载所有已保存的服务器配置,自动连接已启用的服务器 + async init(): Promise { + try { + const servers = await this.repo.listServers(); + for (const server of servers) { + if (server.enabled) { + try { + await this.connectServer(server.id); + } catch { + // 连接失败不影响其他服务器 + } + } + } + } catch { + // 加载失败静默忽略 + } + } + + // 连接服务器:创建 MCPClient,初始化,列出工具,注册到 ToolRegistry + async connectServer(id: string): Promise { + const config = await this.repo.getServer(id); + if (!config) { + throw new Error(`MCP server "${id}" not found`); + } + + // 如果已连接,先断开 + if (this.clients.has(id)) { + await this.disconnectServer(id); + } + + const client = this.createClient(config); + await client.initialize(); + + // 列出工具 + const tools = await client.listTools(); + this.clients.set(id, client); + + // 注册工具到 ToolRegistry + const toolNames: string[] = []; + for (const tool of tools) { + const name = mcpToolName(config.name, tool.name); + const definition: ToolDefinition = { + name, + description: `[MCP: ${config.name}] ${tool.description || tool.name}`, + parameters: tool.inputSchema, + }; + this.toolRegistry.register("mcp", definition, new MCPToolExecutor(client, tool.name)); + toolNames.push(name); + } + this.registeredTools.set(id, toolNames); + + return tools; + } + + // 确保服务器已连接(懒连接) + private async ensureConnected(serverId: string): Promise { + let client = this.clients.get(serverId); + if (client && client.isInitialized()) { + return client; + } + await this.connectServer(serverId); + client = this.clients.get(serverId); + if (!client) { + throw new Error(`Failed to connect to MCP server "${serverId}"`); + } + return client; + } + + // 断开服务器连接,注销所有工具 + async disconnectServer(id: string): Promise { + const toolNames = this.registeredTools.get(id); + if (toolNames) { + for (const name of toolNames) { + this.toolRegistry.unregister(name); + } + this.registeredTools.delete(id); + } + + const client = this.clients.get(id); + if (client) { + client.close(); + this.clients.delete(id); + } + } + + // 测试连接:初始化 + listTools + async testConnection(id: string): Promise<{ tools: number; resources: number; prompts: number }> { + const config = await this.repo.getServer(id); + if (!config) { + throw new Error(`MCP server "${id}" not found`); + } + + const client = this.createClient(config); + try { + await client.initialize(); + const [tools, resources, prompts] = await Promise.all([ + client.listTools().catch(() => []), + client.listResources().catch(() => []), + client.listPrompts().catch(() => []), + ]); + return { + tools: tools.length, + resources: resources.length, + prompts: prompts.length, + }; + } finally { + client.close(); + } + } + + // 处理 MCP API 请求 + async handleMCPApi(request: MCPApiRequest): Promise { + switch (request.action) { + case "listServers": + return this.repo.listServers(); + + case "getServer": { + const server = await this.repo.getServer(request.id); + if (!server) throw new Error(`MCP server "${request.id}" not found`); + return server; + } + + case "addServer": { + const now = Date.now(); + const config: MCPServerConfig = { + ...request.config, + id: uuidv4(), + createtime: now, + updatetime: now, + }; + await this.repo.saveServer(config); + // 如果启用了,连接服务器 + if (config.enabled) { + try { + await this.connectServer(config.id); + } catch { + // 连接失败不影响保存 + } + } + return config; + } + + case "updateServer": { + const existing = await this.repo.getServer(request.id); + if (!existing) throw new Error(`MCP server "${request.id}" not found`); + const updated: MCPServerConfig = { + ...existing, + ...request.config, + id: existing.id, // 不允许修改 ID + createtime: existing.createtime, // 不允许修改创建时间 + updatetime: Date.now(), + }; + await this.repo.saveServer(updated); + + // 处理 enabled 状态变更 + if (updated.enabled && !this.clients.has(request.id)) { + try { + await this.connectServer(request.id); + } catch { + // 连接失败不影响保存 + } + } else if (!updated.enabled && this.clients.has(request.id)) { + await this.disconnectServer(request.id); + } + + return updated; + } + + case "removeServer": { + await this.disconnectServer(request.id); + await this.repo.removeServer(request.id); + return true; + } + + case "listTools": { + const client = await this.ensureConnected(request.serverId); + return client.listTools(); + } + + case "listResources": { + const client = await this.ensureConnected(request.serverId); + return client.listResources(); + } + + case "readResource": { + const client = await this.ensureConnected(request.serverId); + return client.readResource(request.uri); + } + + case "listPrompts": { + const client = await this.ensureConnected(request.serverId); + return client.listPrompts(); + } + + case "getPrompt": { + const client = await this.ensureConnected(request.serverId); + return client.getPrompt(request.name, request.args); + } + + case "testConnection": + return this.testConnection(request.id); + + default: + throw new Error(`Unknown MCP action: ${(request as any).action}`); + } + } +} diff --git a/src/app/service/agent/service_worker/model_service.ts b/src/app/service/agent/service_worker/model_service.ts new file mode 100644 index 000000000..5df3ed4a9 --- /dev/null +++ b/src/app/service/agent/service_worker/model_service.ts @@ -0,0 +1,101 @@ +import type { AgentModelConfig, AgentModelSafeConfig, ModelApiRequest } from "@App/app/service/agent/core/types"; +import { AgentModelRepo } from "@App/app/repo/agent_model"; +import { supportsVision, supportsImageOutput } from "@App/pages/options/routes/AgentChat/model_utils"; +import type { Group } from "@Packages/message/server"; + +export class AgentModelService { + modelRepo: AgentModelRepo; + + constructor( + private group: Group, + modelRepo?: AgentModelRepo + ) { + this.modelRepo = modelRepo ?? new AgentModelRepo(); + } + + init(): void { + // Model CRUD(供 Options UI 调用) + this.group.on("listModels", () => this.modelRepo.listModels()); + this.group.on("getModel", (id: string) => this.modelRepo.getModel(id)); + this.group.on("saveModel", (model: AgentModelConfig) => this.modelRepo.saveModel(model)); + this.group.on("removeModel", (id: string) => this.modelRepo.removeModel(id)); + this.group.on("getDefaultModelId", () => this.modelRepo.getDefaultModelId()); + this.group.on("setDefaultModelId", (id: string) => this.modelRepo.setDefaultModelId(id)); + // 摘要模型 API(供 Options UI 调用) + this.group.on("getSummaryModelId", () => this.modelRepo.getSummaryModelId()); + this.group.on("setSummaryModelId", (id: string) => this.modelRepo.setSummaryModelId(id)); + } + + // 获取模型配置(用户指定 ID → 默认 ID → 第一个可用) + async getModel(modelId?: string): Promise { + let model: AgentModelConfig | undefined; + if (modelId) { + model = await this.modelRepo.getModel(modelId); + } + if (!model) { + const defaultId = await this.modelRepo.getDefaultModelId(); + if (defaultId) { + model = await this.modelRepo.getModel(defaultId); + } + } + if (!model) { + const models = await this.modelRepo.listModels(); + if (models.length > 0) { + model = models[0]; + } + } + if (!model) { + throw new Error("No model configured. Please configure a model in Agent settings."); + } + return model; + } + + // 获取 summary 专用模型(回退到默认/首个) + async getSummaryModel(): Promise { + let model: AgentModelConfig | undefined; + const summaryId = await this.modelRepo.getSummaryModelId(); + if (summaryId) { + model = await this.modelRepo.getModel(summaryId); + } + if (!model) { + const defaultId = await this.modelRepo.getDefaultModelId(); + if (defaultId) { + model = await this.modelRepo.getModel(defaultId); + } + } + if (!model) { + throw new Error("No model configured for summarization"); + } + return model; + } + + // 去除敏感字段,同时补充 supportsVision / supportsImageOutput 的自动检测 fallback + stripApiKey(model: AgentModelConfig): AgentModelSafeConfig { + const { apiKey: _, ...safe } = model; + safe.supportsVision = supportsVision(model); + safe.supportsImageOutput = supportsImageOutput(model); + return safe; + } + + // 处理 CAT.agent.model API 请求(只读,隐藏 apiKey),供 GMApi 调用 + async handleModelApi( + request: ModelApiRequest + ): Promise { + switch (request.action) { + case "list": { + const models = await this.modelRepo.listModels(); + return models.map((m) => this.stripApiKey(m)); + } + case "get": { + const model = await this.modelRepo.getModel(request.id); + return model ? this.stripApiKey(model) : null; + } + case "getDefault": + return this.modelRepo.getDefaultModelId(); + case "getSummary": + return this.modelRepo.getSummaryModelId(); + default: + throw new Error(`Unknown model API action: ${(request as any).action}`); + } + } +} diff --git a/src/app/service/agent/service_worker/opfs.test.ts b/src/app/service/agent/service_worker/opfs.test.ts new file mode 100644 index 000000000..9f2377f0b --- /dev/null +++ b/src/app/service/agent/service_worker/opfs.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi } from "vitest"; +import { createTestService } from "./test-helpers"; + +// ---- handleOPFSApi 测试 ---- + +describe("handleOPFSApi", () => { + // mock sender: getSender() 返回 truthy → supportBlob = false → 使用 blobUrl(chrome.runtime 通道) + const mockOPFSSender = { getSender: () => ({ id: "test" }) } as any; + + // 构建内存 OPFS mock(与 opfs_tools.test.ts 相同逻辑) + type FSNode = { kind: "file"; content: string } | { kind: "directory"; children: Map }; + + function createMockFS() { + const root: FSNode = { kind: "directory", children: new Map() }; + + function makeDirectoryHandle(node: FSNode & { kind: "directory" }, name = ""): any { + return { + kind: "directory", + name, + getDirectoryHandle(childName: string, opts?: { create?: boolean }) { + let child = node.children.get(childName); + if (!child) { + if (opts?.create) { + child = { kind: "directory", children: new Map() }; + node.children.set(childName, child); + } else { + throw new DOMException(`"${childName}" not found`, "NotFoundError"); + } + } + if (child.kind !== "directory") throw new DOMException("Not a directory", "TypeMismatchError"); + return makeDirectoryHandle(child, childName); + }, + getFileHandle(childName: string, opts?: { create?: boolean }) { + let child = node.children.get(childName); + if (!child) { + if (opts?.create) { + child = { kind: "file", content: "" }; + node.children.set(childName, child); + } else { + throw new DOMException(`"${childName}" not found`, "NotFoundError"); + } + } + if (child.kind !== "file") throw new DOMException("Not a file", "TypeMismatchError"); + return makeFileHandle(child, childName); + }, + removeEntry(childName: string) { + if (!node.children.has(childName)) throw new DOMException(`"${childName}" not found`, "NotFoundError"); + node.children.delete(childName); + }, + async *[Symbol.asyncIterator]() { + for (const [n, c] of node.children) { + if (c.kind === "file") yield [n, makeFileHandle(c as FSNode & { kind: "file" }, n)]; + else yield [n, makeDirectoryHandle(c as FSNode & { kind: "directory" }, n)]; + } + }, + }; + } + + function makeFileHandle(node: FSNode & { kind: "file" }, name: string): any { + return { + kind: "file", + name, + async getFile() { + return new Blob([node.content], { type: "text/plain" }); + }, + async createWritable() { + let buffer = ""; + return { + async write(data: string) { + buffer += data; + }, + async close() { + node.content = buffer; + }, + }; + }, + }; + } + + return { rootHandle: makeDirectoryHandle(root, "") }; + } + + function setupOPFS() { + const mockFS = createMockFS(); + vi.stubGlobal("navigator", { + ...globalThis.navigator, + storage: { getDirectory: vi.fn().mockResolvedValue(mockFS.rootHandle) }, + }); + return mockFS; + } + + it("write + read 应正确写入和读取文件", async () => { + setupOPFS(); + const { service } = createTestService(); + + const writeResult = (await service.handleOPFSApi( + { + action: "write", + path: "test.txt", + content: "Hello OPFS", + scriptUuid: "s1", + }, + mockOPFSSender + )) as any; + expect(writeResult.path).toBe("test.txt"); + expect(writeResult.size).toBe(10); + + const readResult = (await service.handleOPFSApi( + { + action: "read", + path: "test.txt", + scriptUuid: "s1", + }, + mockOPFSSender + )) as any; + expect(readResult.content).toBe("Hello OPFS"); + }); + + it("list 应返回目录内容", async () => { + setupOPFS(); + const { service } = createTestService(); + + await service.handleOPFSApi({ action: "write", path: "a.txt", content: "a", scriptUuid: "s1" }, mockOPFSSender); + await service.handleOPFSApi( + { action: "write", path: "dir/b.txt", content: "bb", scriptUuid: "s1" }, + mockOPFSSender + ); + + const listResult = (await service.handleOPFSApi({ action: "list", scriptUuid: "s1" }, mockOPFSSender)) as any[]; + expect(listResult).toHaveLength(2); + expect(listResult.find((e: any) => e.name === "a.txt")).toBeDefined(); + expect(listResult.find((e: any) => e.name === "dir")).toBeDefined(); + }); + + it("delete 应删除文件", async () => { + setupOPFS(); + const { service } = createTestService(); + + await service.handleOPFSApi({ action: "write", path: "tmp.txt", content: "x", scriptUuid: "s1" }, mockOPFSSender); + const delResult = (await service.handleOPFSApi( + { action: "delete", path: "tmp.txt", scriptUuid: "s1" }, + mockOPFSSender + )) as any; + expect(delResult.success).toBe(true); + + await expect( + service.handleOPFSApi({ action: "read", path: "tmp.txt", scriptUuid: "s1" }, mockOPFSSender) + ).rejects.toThrow(); + }); + + it("未知 action 应抛出错误", async () => { + const { service } = createTestService(); + await expect(service.handleOPFSApi({ action: "unknown" as any, scriptUuid: "s1" }, mockOPFSSender)).rejects.toThrow( + "Unknown OPFS action" + ); + }); + + it("readAttachment 应返回 blobUrl", async () => { + const { service, mockRepo } = createTestService(); + const testBlob = new Blob(["test image data"], { type: "image/png" }); + mockRepo.getAttachment = vi.fn().mockResolvedValue(testBlob); + + const result = (await service.handleOPFSApi( + { + action: "readAttachment", + id: "att-123", + scriptUuid: "s1", + }, + mockOPFSSender + )) as any; + + expect(result.id).toBe("att-123"); + expect(result.blobUrl).toBe("blob:chrome-extension://test/mock-blob-url"); + expect(result.size).toBe(testBlob.size); + expect(result.mimeType).toBe("image/png"); + expect(mockRepo.getAttachment).toHaveBeenCalledWith("att-123"); + }); + + it("readAttachment 附件不存在时应抛出错误", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.getAttachment = vi.fn().mockResolvedValue(null); + + await expect( + service.handleOPFSApi({ action: "readAttachment", id: "not-exist", scriptUuid: "s1" }, mockOPFSSender) + ).rejects.toThrow("Attachment not found: not-exist"); + }); + + it("read blob 格式应返回 blobUrl", async () => { + setupOPFS(); + const { service } = createTestService(); + + await service.handleOPFSApi( + { action: "write", path: "img.png", content: "fake png", scriptUuid: "s1" }, + mockOPFSSender + ); + + const result = (await service.handleOPFSApi( + { + action: "read", + path: "img.png", + format: "blob", + scriptUuid: "s1", + }, + mockOPFSSender + )) as any; + + expect(result.path).toBe("img.png"); + expect(result.blobUrl).toBe("blob:chrome-extension://test/mock-blob-url"); + expect(result.mimeType).toBe("image/png"); + expect(result.size).toBeGreaterThan(0); + }); +}); diff --git a/src/app/service/agent/service_worker/opfs_service.ts b/src/app/service/agent/service_worker/opfs_service.ts new file mode 100644 index 000000000..ac3a51d30 --- /dev/null +++ b/src/app/service/agent/service_worker/opfs_service.ts @@ -0,0 +1,91 @@ +import type { IGetSender } from "@Packages/message/server"; +import type { MessageSend } from "@Packages/message/types"; +import type { OPFSApiRequest } from "@App/app/service/agent/core/types"; +import { createOPFSTools, guessMimeType } from "@App/app/service/agent/core/tools/opfs_tools"; +import { sanitizePath, getWorkspaceRoot, getDirectory, splitPath } from "@App/app/service/agent/core/opfs_helpers"; +import { createObjectURL } from "@App/app/service/offscreen/client"; +import { sendMessage } from "@Packages/message/client"; +import type { AgentChatRepo } from "@App/app/repo/agent_chat"; + +export class AgentOPFSService { + constructor(private sender: MessageSend) {} + + // 处理 CAT.agent.opfs API 请求 + // sender.getSender() 有值 → 来自 chrome.runtime(content script),不支持 Blob + // sender.getSender() 为空 → 来自 postMessage(offscreen),支持 Blob + async handleOPFSApi(request: OPFSApiRequest, sender: IGetSender, repo: AgentChatRepo): Promise { + const supportBlob = !sender.getSender(); + const opfsTools = createOPFSTools(); + const toolMap = new Map(opfsTools.tools.map((t) => [t.definition.name, t.executor])); + + switch (request.action) { + case "write": { + let content = request.content; + // chrome.runtime 通道:content script 已将 Blob 转为 blob URL,需还原 + if (!supportBlob && typeof content === "string" && content.startsWith("blob:")) { + content = await this.fetchBlobFromOffscreen(content); + } + const executor = toolMap.get("opfs_write")!; + return JSON.parse((await executor.execute({ path: request.path, content })) as string); + } + case "read": { + if (request.format === "blob") { + const safePath = sanitizePath(request.path); + if (!safePath) throw new Error("path is required"); + const workspace = await getWorkspaceRoot(); + const { dirPath, fileName } = splitPath(safePath); + const dir = dirPath ? await getDirectory(workspace, dirPath) : workspace; + const fileHandle = await dir.getFileHandle(fileName); + const file = await fileHandle.getFile(); + const mimeType = guessMimeType(safePath); + const blob = new Blob([await file.arrayBuffer()], { type: mimeType }); + if (supportBlob) { + // postMessage 通道:直接返回 Blob + return { path: safePath, data: blob, size: file.size, mimeType }; + } + // chrome.runtime 通道:通过 offscreen 创建 blob URL,客户端通过 CAT_fetchBlob 还原 + const blobUrl = (await createObjectURL(this.sender, { blob, persistence: true })) as string; + return { path: safePath, blobUrl, size: file.size, mimeType }; + } + // 默认 text 模式:直接返回文件文本内容(GM API 独立实现,不走 opfs_read executor 的分页逻辑) + const safePath2 = sanitizePath(request.path); + if (!safePath2) throw new Error("path is required"); + const workspace2 = await getWorkspaceRoot(); + const { dirPath: dirPath2, fileName: fileName2 } = splitPath(safePath2); + const dir2 = dirPath2 ? await getDirectory(workspace2, dirPath2) : workspace2; + const fileHandle2 = await dir2.getFileHandle(fileName2); + const file2 = await fileHandle2.getFile(); + const textContent = await file2.text(); + return { path: safePath2, content: textContent, size: file2.size }; + } + case "readAttachment": { + const blob = await repo.getAttachment(request.id); + if (!blob) { + throw new Error(`Attachment not found: ${request.id}`); + } + if (supportBlob) { + // postMessage 通道:直接返回 Blob + return { id: request.id, data: blob, size: blob.size, mimeType: blob.type }; + } + // chrome.runtime 通道:通过 offscreen 创建 blob URL,客户端通过 CAT_fetchBlob 还原 + const blobUrl = (await createObjectURL(this.sender, { blob, persistence: true })) as string; + return { id: request.id, blobUrl, size: blob.size, mimeType: blob.type }; + } + case "list": { + const executor = toolMap.get("opfs_list")!; + return JSON.parse((await executor.execute({ path: request.path || "" })) as string); + } + case "delete": { + const executor = toolMap.get("opfs_delete")!; + return JSON.parse((await executor.execute({ path: request.path })) as string); + } + default: + throw new Error(`Unknown OPFS action: ${(request as any).action}`); + } + } + + // 通过 offscreen fetch blob URL 还原为 Blob(用于 chrome.runtime 通道下 content script 传来的 blob URL) + private async fetchBlobFromOffscreen(blobUrl: string): Promise { + return (await sendMessage(this.sender, "offscreen/fetchBlob", { url: blobUrl })) as Blob; + } +} diff --git a/src/app/service/agent/service_worker/retry.test.ts b/src/app/service/agent/service_worker/retry.test.ts new file mode 100644 index 000000000..7bec6361a --- /dev/null +++ b/src/app/service/agent/service_worker/retry.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi } from "vitest"; +import { isRetryableError, withRetry, classifyErrorCode } from "./agent"; + +// ---- isRetryableError ---- + +describe("isRetryableError", () => { + it("429 应可重试", () => { + expect(isRetryableError(new Error("HTTP 429 Too Many Requests"))).toBe(true); + }); + + it("500 应可重试", () => { + expect(isRetryableError(new Error("HTTP 500 Internal Server Error"))).toBe(true); + }); + + it("503 应可重试", () => { + expect(isRetryableError(new Error("503 Service Unavailable"))).toBe(true); + }); + + it("network 错误应可重试", () => { + expect(isRetryableError(new Error("network error"))).toBe(true); + expect(isRetryableError(new Error("Network Error"))).toBe(true); + }); + + it("fetch 失败应可重试", () => { + expect(isRetryableError(new Error("fetch failed"))).toBe(true); + }); + + it("ECONNRESET 应可重试", () => { + expect(isRetryableError(new Error("ECONNRESET"))).toBe(true); + }); + + it("401 不应重试", () => { + expect(isRetryableError(new Error("401 Unauthorized"))).toBe(false); + }); + + it("403 不应重试", () => { + expect(isRetryableError(new Error("403 Forbidden"))).toBe(false); + }); + + it("400 不应重试", () => { + expect(isRetryableError(new Error("400 Bad Request"))).toBe(false); + }); + + it("404 不应重试", () => { + expect(isRetryableError(new Error("404 Not Found"))).toBe(false); + }); + + it("普通错误不应重试", () => { + expect(isRetryableError(new Error("Invalid API key"))).toBe(false); + expect(isRetryableError(new Error("JSON parse error"))).toBe(false); + }); +}); + +// ---- withRetry ---- + +// 测试用的立即返回 delay(避免真实等待和 fake timer 复杂性) +const immediateDelay = () => Promise.resolve(); + +describe("withRetry", () => { + it("首次成功时直接返回结果", async () => { + const fn = vi.fn().mockResolvedValue("ok"); + const signal = new AbortController().signal; + const result = await withRetry(fn, signal, 3, immediateDelay); + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("429 错误应重试直到成功", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("HTTP 429 Too Many Requests")) + .mockRejectedValueOnce(new Error("HTTP 429 Too Many Requests")) + .mockResolvedValue("ok"); + const signal = new AbortController().signal; + + const result = await withRetry(fn, signal, 3, immediateDelay); + + expect(result).toBe("ok"); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("超过最大重试次数后抛出最后的错误", async () => { + const fn = vi.fn().mockRejectedValue(new Error("HTTP 429 Too Many Requests")); + const signal = new AbortController().signal; + + await expect(withRetry(fn, signal, 3, immediateDelay)).rejects.toThrow("429"); + // 1 次首次尝试 + 3 次重试 = 4 次 + expect(fn).toHaveBeenCalledTimes(4); + }); + + it("401 错误不重试,直接抛出", async () => { + const fn = vi.fn().mockRejectedValue(new Error("401 Unauthorized")); + const signal = new AbortController().signal; + + await expect(withRetry(fn, signal, 3, immediateDelay)).rejects.toThrow("401"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("pre-abort 时不调用 fn,直接抛出", async () => { + const ac = new AbortController(); + ac.abort(); + + const fn = vi.fn().mockResolvedValue("ok"); + + await expect(withRetry(fn, ac.signal, 3, immediateDelay)).rejects.toThrow(); + // 信号已 abort,循环开头立即退出,fn 从未被调用 + expect(fn).toHaveBeenCalledTimes(0); + }); + + it("fn 内 abort 后不再重试", async () => { + const ac = new AbortController(); + // fn 执行时同步 abort,模拟外部取消 + const fn = vi.fn().mockImplementation(() => { + ac.abort(); + return Promise.reject(new Error("HTTP 500")); + }); + + await expect(withRetry(fn, ac.signal, 3, immediateDelay)).rejects.toThrow(); + // fn 被调用一次后 abort,catch 分支检测到 signal.aborted,立即抛出不再重试 + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("500 错误重试后成功", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error("HTTP 500 Internal Server Error")) + .mockResolvedValue("recovered"); + const signal = new AbortController().signal; + + const result = await withRetry(fn, signal, 3, immediateDelay); + expect(result).toBe("recovered"); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); + +// ---- classifyErrorCode ---- + +describe("classifyErrorCode", () => { + it("429 应分类为 rate_limit", () => { + expect(classifyErrorCode(new Error("HTTP 429 Too Many Requests"))).toBe("rate_limit"); + }); + + it("401 应分类为 auth", () => { + expect(classifyErrorCode(new Error("401 Unauthorized"))).toBe("auth"); + }); + + it("403 应分类为 auth", () => { + expect(classifyErrorCode(new Error("403 Forbidden"))).toBe("auth"); + }); + + it("消息含 timed out 应分类为 tool_timeout", () => { + expect(classifyErrorCode(new Error('SkillScript "foo" timed out after 30s'))).toBe("tool_timeout"); + }); + + it("errorCode 属性为 tool_timeout 应分类为 tool_timeout", () => { + const e = Object.assign(new Error("execution failed"), { errorCode: "tool_timeout" }); + expect(classifyErrorCode(e)).toBe("tool_timeout"); + }); + + it("其他错误应分类为 api_error", () => { + expect(classifyErrorCode(new Error("500 Internal Server Error"))).toBe("api_error"); + expect(classifyErrorCode(new Error("Unknown error"))).toBe("api_error"); + }); +}); diff --git a/src/app/service/agent/service_worker/retry_utils.ts b/src/app/service/agent/service_worker/retry_utils.ts new file mode 100644 index 000000000..c2ec3e87e --- /dev/null +++ b/src/app/service/agent/service_worker/retry_utils.ts @@ -0,0 +1,53 @@ +// 判断是否可重试(429 / 5xx / 网络错误,不含 4xx 客户端错误) +export function isRetryableError(e: Error): boolean { + const msg = e.message; + return /429|5\d\d|network|fetch|ECONNRESET/i.test(msg) && !/40[0134]/.test(msg); +} + +// 指数退避重试,aborted 时立即退出 +// delayFn 仅供测试注入,生产代码不传 +export async function withRetry( + fn: () => Promise, + signal: AbortSignal, + maxRetries = 3, + delayFn?: (ms: number, signal: AbortSignal) => Promise +): Promise { + const wait = + delayFn ?? + ((ms, sig) => + new Promise((r) => { + const t = setTimeout(r, ms); + sig.addEventListener( + "abort", + () => { + clearTimeout(t); + r(); + }, + { once: true } + ); + })); + + let lastError!: Error; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (signal.aborted) throw lastError ?? new Error("Aborted"); + try { + return await fn(); + } catch (e: any) { + if (signal.aborted) throw e; + lastError = e; + if (!isRetryableError(e) || attempt === maxRetries) throw e; + const delay = 1000 * Math.pow(2, attempt) + Math.random() * 1000; + await wait(delay, signal); + } + } + throw lastError; +} + +// 将 Error 分类为 errorCode 字符串 +export function classifyErrorCode(e: Error): string { + const msg = e.message; + if (/429/.test(msg)) return "rate_limit"; + if (/401|403/.test(msg)) return "auth"; + if (/timed out/.test(msg) || (e as any).errorCode === "tool_timeout") return "tool_timeout"; + return "api_error"; +} diff --git a/src/app/service/agent/service_worker/skill-cleanup.test.ts b/src/app/service/agent/service_worker/skill-cleanup.test.ts new file mode 100644 index 000000000..308ace9bc --- /dev/null +++ b/src/app/service/agent/service_worker/skill-cleanup.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { AgentService } from "./agent"; +import { createTestService, makeSkillRecord, makeSkillScriptRecord } from "./test-helpers"; + +// ---- handleConversationChat skill 动态工具清理测试 ---- + +describe("handleConversationChat skill 动态工具清理", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + // 辅助:创建 mock sender + function createMockSender() { + const sentMessages: any[] = []; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const sender = { + isType: (type: any) => type === 1, // GetSenderType.CONNECT + getConnect: () => mockConn, + }; + return { sender, sentMessages }; + } + + it("对话结束后应清理 meta-tools", async () => { + const { service, mockRepo, mockSkillRepo } = createTestService(); + const { sender } = createMockSender(); + + // 设置 skill 带工具 + const scriptRecord = makeSkillScriptRecord({ + name: "my-tool", + description: "A tool", + params: [], + }); + const skill = makeSkillRecord({ + name: "test-skill", + toolNames: ["my-tool"], + referenceNames: [], + prompt: "Test prompt.", + }); + (service as any).skillCache.set("test-skill", skill); + + // mock conversation 存在且带 skills + mockRepo.listConversations.mockResolvedValue([ + { + id: "conv-1", + title: "Test", + modelId: "test-openai", + skills: "auto", + createtime: Date.now(), + updatetime: Date.now(), + }, + ]); + + // load_skill 调用时返回脚本记录 + mockSkillRepo.getSkillScripts.mockResolvedValueOnce([scriptRecord]); + + // 构造 SSE:LLM 调用 load_skill,然后纯文本结束 + const encoder = new TextEncoder(); + // 第一次 fetch:返回 load_skill tool call + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + getReader: () => { + const chunks = [ + `data: {"choices":[{"delta":{"tool_calls":[{"id":"call_1","function":{"name":"load_skill","arguments":""}}]}}]}\n\n`, + `data: {"choices":[{"delta":{"tool_calls":[{"function":{"arguments":"{\\"skill_name\\":\\"test-skill\\"}"}}]}}]}\n\n`, + `data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`, + ]; + let i = 0; + return { + read: async () => { + if (i >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: encoder.encode(chunks[i++]) }; + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + }; + }, + }, + text: async () => "", + } as unknown as Response); + + // 第二次 fetch:纯文本结束 + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + getReader: () => { + const chunks = [ + `data: {"choices":[{"delta":{"content":"完成"}}]}\n\n`, + `data: {"usage":{"prompt_tokens":20,"completion_tokens":8}}\n\n`, + ]; + let i = 0; + return { + read: async () => { + if (i >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: encoder.encode(chunks[i++]) }; + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + }; + }, + }, + text: async () => "", + } as unknown as Response); + + const registry = (service as any).toolRegistry; + + // 对话前 registry 不应有 load_skill 和 execute_skill_script + expect(registry.getDefinitions().find((d: any) => d.name === "load_skill")).toBeUndefined(); + expect(registry.getDefinitions().find((d: any) => d.name === "execute_skill_script")).toBeUndefined(); + + await (service as any).handleConversationChat({ conversationId: "conv-1", message: "test" }, sender); + + // 对话后 meta-tools 应已清理 + expect(registry.getDefinitions().find((d: any) => d.name === "load_skill")).toBeUndefined(); + expect(registry.getDefinitions().find((d: any) => d.name === "execute_skill_script")).toBeUndefined(); + }); +}); + +// ---- init() 消息注册测试 ---- + +describe("AgentService init() 消息注册", () => { + it("应注册 installSkill 和 removeSkill 消息处理", () => { + const mockGroup = { on: vi.fn() } as any; + const mockSender = {} as any; + + const service = new AgentService(mockGroup, mockSender); + + // 替换 repos 避免 OPFS 调用 + (service as any).skillRepo = { listSkills: vi.fn().mockResolvedValue([]) }; + + service.init(); + + // 收集所有 group.on 注册的消息名 + const registeredNames = mockGroup.on.mock.calls.map((call: any[]) => call[0]); + + expect(registeredNames).toContain("installSkill"); + expect(registeredNames).toContain("removeSkill"); + }); + + it("installSkill 消息处理应正确转发参数", async () => { + const mockGroup = { on: vi.fn() } as any; + const mockSender = {} as any; + + const service = new AgentService(mockGroup, mockSender); + + const mockSkillRepo = { + listSkills: vi.fn().mockResolvedValue([]), + getSkill: vi.fn().mockResolvedValue(null), + saveSkill: vi.fn().mockResolvedValue(undefined), + }; + (service as any).skillRepo = mockSkillRepo; + + service.init(); + + // 找到 installSkill 处理函数 + const installSkillCall = mockGroup.on.mock.calls.find((call: any[]) => call[0] === "installSkill"); + expect(installSkillCall).toBeDefined(); + + const handler = installSkillCall[1]; + const skillMd = `--- +name: msg-test +description: Test via message +--- +Prompt content.`; + + const result = await handler({ skillMd }); + + expect(result.name).toBe("msg-test"); + expect(mockSkillRepo.saveSkill).toHaveBeenCalledTimes(1); + }); + + it("removeSkill 消息处理应正确转发参数", async () => { + const mockGroup = { on: vi.fn() } as any; + const mockSender = {} as any; + + const service = new AgentService(mockGroup, mockSender); + + const mockSkillRepo = { + listSkills: vi.fn().mockResolvedValue([]), + removeSkill: vi.fn().mockResolvedValue(true), + }; + (service as any).skillRepo = mockSkillRepo; + + service.init(); + + // 找到 removeSkill 处理函数 + const removeSkillCall = mockGroup.on.mock.calls.find((call: any[]) => call[0] === "removeSkill"); + expect(removeSkillCall).toBeDefined(); + + const handler = removeSkillCall[1]; + const result = await handler("msg-test-skill"); + + expect(result).toBe(true); + expect(mockSkillRepo.removeSkill).toHaveBeenCalledWith("msg-test-skill"); + }); +}); diff --git a/src/app/service/agent/service_worker/skill.test.ts b/src/app/service/agent/service_worker/skill.test.ts new file mode 100644 index 000000000..df6bfad3f --- /dev/null +++ b/src/app/service/agent/service_worker/skill.test.ts @@ -0,0 +1,514 @@ +import { describe, it, expect, vi } from "vitest"; +import { createTestService, VALID_SKILLSCRIPT_CODE, makeSkillRecord, makeSkillScriptRecord } from "./test-helpers"; + +// ---- Skill 系统测试 ---- + +describe("AgentService Skill 系统", () => { + describe("resolveSkills", () => { + it("无 skills 时返回空", () => { + const { service } = createTestService(); + const result = (service as any).resolveSkills(undefined); + expect(result.promptSuffix).toBe(""); + expect(result.metaTools).toEqual([]); + }); + + it('"auto" 加载全部 skill 摘要', () => { + const { service } = createTestService(); + + const skill1 = makeSkillRecord({ + name: "price-monitor", + description: "监控商品价格", + toolNames: ["price-check"], + prompt: "Monitor prices.", + }); + const skill2 = makeSkillRecord({ + name: "translator", + description: "翻译助手", + referenceNames: ["glossary"], + prompt: "Translate text.", + }); + (service as any).skillCache.set("price-monitor", skill1); + (service as any).skillCache.set("translator", skill2); + + const result = (service as any).resolveSkills("auto"); + + // promptSuffix 应包含两个 skill 的 name + description + expect(result.promptSuffix).toContain("price-monitor"); + expect(result.promptSuffix).toContain("监控商品价格"); + expect(result.promptSuffix).toContain("translator"); + expect(result.promptSuffix).toContain("翻译助手"); + + // promptSuffix 不应包含 skill.prompt 内容 + expect(result.promptSuffix).not.toContain("Monitor prices."); + expect(result.promptSuffix).not.toContain("Translate text."); + + // 应返回 3 个 metaTools(load_skill, execute_skill_script, read_reference) + expect(result.metaTools).toHaveLength(3); + const names = result.metaTools.map((t: any) => t.definition.name); + expect(names).toContain("load_skill"); + expect(names).toContain("execute_skill_script"); + expect(names).toContain("read_reference"); + }); + + it("指定名称过滤", () => { + const { service } = createTestService(); + + const skill1 = makeSkillRecord({ name: "skill-a", description: "Skill A" }); + const skill2 = makeSkillRecord({ name: "skill-b", description: "Skill B" }); + (service as any).skillCache.set("skill-a", skill1); + (service as any).skillCache.set("skill-b", skill2); + + const result = (service as any).resolveSkills(["skill-a"]); + + expect(result.promptSuffix).toContain("skill-a"); + expect(result.promptSuffix).toContain("Skill A"); + expect(result.promptSuffix).not.toContain("skill-b"); + expect(result.promptSuffix).not.toContain("Skill B"); + }); + + it("无工具/参考资料的 skill 注册 load_skill + execute_skill_script", () => { + const { service } = createTestService(); + + const skill = makeSkillRecord({ name: "simple-skill", toolNames: [], referenceNames: [] }); + (service as any).skillCache.set("simple-skill", skill); + + const result = (service as any).resolveSkills("auto"); + + expect(result.metaTools).toHaveLength(2); + const names = result.metaTools.map((t: any) => t.definition.name); + expect(names).toContain("load_skill"); + expect(names).toContain("execute_skill_script"); + }); + + it("有工具无参考资料时注册 load_skill + execute_skill_script", () => { + const { service } = createTestService(); + + const skill = makeSkillRecord({ name: "tools-only", toolNames: ["my-tool"], referenceNames: [] }); + (service as any).skillCache.set("tools-only", skill); + + const result = (service as any).resolveSkills("auto"); + + expect(result.metaTools).toHaveLength(2); + const names = result.metaTools.map((t: any) => t.definition.name); + expect(names).toContain("load_skill"); + expect(names).toContain("execute_skill_script"); + }); + + it("有参考资料无工具时注册 load_skill + execute_skill_script + read_reference", () => { + const { service } = createTestService(); + + const skill = makeSkillRecord({ name: "refs-only", toolNames: [], referenceNames: ["doc.md"] }); + (service as any).skillCache.set("refs-only", skill); + + const result = (service as any).resolveSkills("auto"); + + expect(result.metaTools).toHaveLength(3); + const names = result.metaTools.map((t: any) => t.definition.name); + expect(names).toContain("load_skill"); + expect(names).toContain("execute_skill_script"); + expect(names).toContain("read_reference"); + }); + }); + + describe("load_skill meta-tool", () => { + it("返回完整 prompt", async () => { + const { service } = createTestService(); + + const skill = makeSkillRecord({ name: "my-skill", prompt: "Detailed instructions here." }); + (service as any).skillCache.set("my-skill", skill); + + const result = (service as any).resolveSkills("auto"); + const loadSkill = result.metaTools.find((t: any) => t.definition.name === "load_skill"); + + const output = await loadSkill.executor.execute({ skill_name: "my-skill" }); + expect(output).toBe("Detailed instructions here."); + }); + + it("skill 不存在时抛错", async () => { + const { service } = createTestService(); + + const skill = makeSkillRecord({ name: "existing" }); + (service as any).skillCache.set("existing", skill); + + const result = (service as any).resolveSkills("auto"); + const loadSkill = result.metaTools.find((t: any) => t.definition.name === "load_skill"); + + await expect(loadSkill.executor.execute({ skill_name: "non-existent" })).rejects.toThrow( + 'Skill "non-existent" not found' + ); + }); + + it("load_skill 返回 prompt 并附带脚本描述", async () => { + const { service, mockSkillRepo } = createTestService(); + + const scriptRecord = makeSkillScriptRecord({ + name: "price-check", + description: "Check price", + params: [{ name: "url", type: "string", description: "Target URL", required: true }], + grants: [], + }); + const skill = makeSkillRecord({ name: "price-skill", toolNames: ["price-check"], prompt: "Monitor prices." }); + (service as any).skillCache.set("price-skill", skill); + + mockSkillRepo.getSkillScripts.mockResolvedValueOnce([scriptRecord]); + + const result = (service as any).resolveSkills("auto"); + const loadSkill = result.metaTools.find((t: any) => t.definition.name === "load_skill"); + + const output = await loadSkill.executor.execute({ skill_name: "price-skill" }); + + // 返回的 prompt 应包含脚本描述信息 + expect(output).toContain("Monitor prices."); + expect(output).toContain("price-check"); + expect(output).toContain("Check price"); + expect(output).toContain("execute_skill_script"); + + // 验证 getSkillScripts 被正确调用 + expect(mockSkillRepo.getSkillScripts).toHaveBeenCalledWith("price-skill"); + }); + + it("无工具的 skill 不调用 getSkillScripts", async () => { + const { service, mockSkillRepo } = createTestService(); + + const skill = makeSkillRecord({ name: "no-tools", toolNames: [], prompt: "Simple prompt." }); + (service as any).skillCache.set("no-tools", skill); + + const result = (service as any).resolveSkills("auto"); + const loadSkill = result.metaTools.find((t: any) => t.definition.name === "load_skill"); + + await loadSkill.executor.execute({ skill_name: "no-tools" }); + expect(mockSkillRepo.getSkillScripts).not.toHaveBeenCalled(); + }); + + it("多个脚本应全部包含在 prompt 中", async () => { + const { service, mockSkillRepo } = createTestService(); + + const tool1 = makeSkillScriptRecord({ + name: "extract", + description: "提取数据", + params: [{ name: "url", type: "string", description: "URL", required: true }], + }); + const tool2 = makeSkillScriptRecord({ + name: "compare", + description: "比较价格", + params: [ + { name: "a", type: "number", description: "价格A", required: true }, + { name: "b", type: "number", description: "价格B", required: true }, + ], + }); + + const skill = makeSkillRecord({ name: "taobao", toolNames: ["extract", "compare"], prompt: "淘宝助手。" }); + (service as any).skillCache.set("taobao", skill); + + mockSkillRepo.getSkillScripts.mockResolvedValueOnce([tool1, tool2]); + + const result = (service as any).resolveSkills("auto"); + const loadSkill = result.metaTools.find((t: any) => t.definition.name === "load_skill"); + + const output = await loadSkill.executor.execute({ skill_name: "taobao" }); + + // prompt 应包含所有脚本的描述 + expect(output).toContain("extract"); + expect(output).toContain("提取数据"); + expect(output).toContain("compare"); + expect(output).toContain("比较价格"); + }); + + it("重复 load_skill 同一 skill 应幂等(返回缓存 prompt)", async () => { + const { service, mockSkillRepo } = createTestService(); + + const scriptRecord = makeSkillScriptRecord({ + name: "my-tool", + description: "V1", + params: [], + }); + const skill = makeSkillRecord({ name: "my-skill", toolNames: ["my-tool"], prompt: "My prompt." }); + (service as any).skillCache.set("my-skill", skill); + + // 第一次 load + mockSkillRepo.getSkillScripts.mockResolvedValueOnce([scriptRecord]); + const result = (service as any).resolveSkills("auto"); + const loadSkill = result.metaTools.find((t: any) => t.definition.name === "load_skill"); + await loadSkill.executor.execute({ skill_name: "my-skill" }); + + // 第二次 load 应直接返回 prompt(不再调用 getSkillScripts) + const output2 = await loadSkill.executor.execute({ skill_name: "my-skill" }); + + expect(output2).toBe(skill.prompt); + // getSkillScripts 只应被调用一次(第一次 load 时) + expect(mockSkillRepo.getSkillScripts).toHaveBeenCalledTimes(1); + }); + }); + + describe("read_reference meta-tool", () => { + it("正常返回参考资料内容", async () => { + const { service, mockSkillRepo } = createTestService(); + + const skill = makeSkillRecord({ name: "ref-skill", referenceNames: ["api-doc"] }); + (service as any).skillCache.set("ref-skill", skill); + + mockSkillRepo.getReference.mockResolvedValueOnce({ name: "api-doc", content: "API documentation content" }); + + const result = (service as any).resolveSkills("auto"); + const readRef = result.metaTools.find((t: any) => t.definition.name === "read_reference"); + + const output = await readRef.executor.execute({ skill_name: "ref-skill", reference_name: "api-doc" }); + expect(output).toBe("API documentation content"); + expect(mockSkillRepo.getReference).toHaveBeenCalledWith("ref-skill", "api-doc"); + }); + + it("不存在时抛错", async () => { + const { service, mockSkillRepo } = createTestService(); + + const skill = makeSkillRecord({ name: "ref-skill", referenceNames: ["doc"] }); + (service as any).skillCache.set("ref-skill", skill); + + mockSkillRepo.getReference.mockResolvedValueOnce(null); + + const result = (service as any).resolveSkills("auto"); + const readRef = result.metaTools.find((t: any) => t.definition.name === "read_reference"); + + await expect( + readRef.executor.execute({ skill_name: "ref-skill", reference_name: "missing-doc" }) + ).rejects.toThrow('Reference "missing-doc" not found in skill "ref-skill"'); + }); + }); + + describe("installSkill + resolveSkills 集成", () => { + it("安装后缓存生效", async () => { + const { service, mockSkillRepo } = createTestService(); + + const skillMd = `--- +name: integrated-skill +description: An integrated test skill +--- +Do something useful.`; + + mockSkillRepo.getSkill = vi.fn().mockResolvedValue(null); + // mock saveSkill to succeed + mockSkillRepo.saveSkill = vi.fn().mockResolvedValue(undefined); + + await service.installSkill(skillMd); + + // skillCache 应包含新安装的 skill + expect((service as any).skillCache.has("integrated-skill")).toBe(true); + + const result = (service as any).resolveSkills("auto"); + expect(result.promptSuffix).toContain("integrated-skill"); + expect(result.promptSuffix).toContain("An integrated test skill"); + // 不应包含 prompt 内容 + expect(result.promptSuffix).not.toContain("Do something useful."); + }); + }); + + describe("removeSkill + resolveSkills 集成", () => { + it("卸载后缓存清除", async () => { + const { service, mockSkillRepo } = createTestService(); + + // 先放入缓存 + const skill = makeSkillRecord({ name: "to-remove" }); + (service as any).skillCache.set("to-remove", skill); + + mockSkillRepo.removeSkill.mockResolvedValueOnce(true); + + await service.removeSkill("to-remove"); + + // skillCache 应不再包含 + expect((service as any).skillCache.has("to-remove")).toBe(false); + + const result = (service as any).resolveSkills("auto"); + expect(result.promptSuffix).toBe(""); + expect(result.metaTools).toEqual([]); + }); + }); + + describe("installSkill 完整流程", () => { + it("安装含脚本和参考资料的 Skill", async () => { + const { service, mockSkillRepo } = createTestService(); + + const skillMd = `--- +name: full-skill +description: A skill with tools and refs +--- +You are a full-featured skill.`; + + const scripts = [ + { + name: "my-tool", + code: VALID_SKILLSCRIPT_CODE, + }, + ]; + + const references = [{ name: "api-doc", content: "Some API documentation" }]; + + const record = await service.installSkill(skillMd, scripts, references); + + expect(record.name).toBe("full-skill"); + expect(record.description).toBe("A skill with tools and refs"); + expect(record.prompt).toBe("You are a full-featured skill."); + expect(record.toolNames).toEqual(["test-tool"]); + expect(record.referenceNames).toEqual(["api-doc"]); + + // saveSkill 应被调用,带上脚本和参考资料 + expect(mockSkillRepo.saveSkill).toHaveBeenCalledTimes(1); + const [savedRecord, savedScripts, savedRefs] = mockSkillRepo.saveSkill.mock.calls[0]; + expect(savedRecord.name).toBe("full-skill"); + expect(savedScripts).toHaveLength(1); + expect(savedScripts[0].name).toBe("test-tool"); + expect(savedRefs).toHaveLength(1); + expect(savedRefs[0].name).toBe("api-doc"); + + // skillCache 应包含新安装的 skill + expect((service as any).skillCache.has("full-skill")).toBe(true); + }); + + it("更新已有 Skill 时保留 installtime", async () => { + const { service, mockSkillRepo } = createTestService(); + + const oldInstallTime = 1000000; + mockSkillRepo.getSkill.mockResolvedValueOnce( + makeSkillRecord({ name: "existing-skill", installtime: oldInstallTime }) + ); + + const skillMd = `--- +name: existing-skill +description: Updated description +--- +Updated prompt.`; + + const record = await service.installSkill(skillMd); + + expect(record.installtime).toBe(oldInstallTime); + expect(record.updatetime).toBeGreaterThan(oldInstallTime); + expect(record.description).toBe("Updated description"); + expect(record.prompt).toBe("Updated prompt."); + }); + + it("无效 SKILL.md 应抛出异常", async () => { + const { service } = createTestService(); + + await expect(service.installSkill("not valid skill md")).rejects.toThrow("Invalid SKILL.md"); + }); + + it("含无效 Skill Script 时应抛出异常", async () => { + const { service } = createTestService(); + + const skillMd = `--- +name: bad-scripts +description: Has invalid script +--- +Some prompt.`; + + await expect(service.installSkill(skillMd, [{ name: "bad-tool", code: "not a skillscript" }])).rejects.toThrow( + "Invalid SkillScript" + ); + }); + }); + + describe("removeSkill", () => { + it("删除存在的 Skill 返回 true", async () => { + const { service, mockSkillRepo } = createTestService(); + + (service as any).skillCache.set("to-delete", makeSkillRecord({ name: "to-delete" })); + mockSkillRepo.removeSkill.mockResolvedValueOnce(true); + + const result = await service.removeSkill("to-delete"); + + expect(result).toBe(true); + expect(mockSkillRepo.removeSkill).toHaveBeenCalledWith("to-delete"); + expect((service as any).skillCache.has("to-delete")).toBe(false); + }); + + it("删除不存在的 Skill 返回 false 且不影响缓存", async () => { + const { service, mockSkillRepo } = createTestService(); + + mockSkillRepo.removeSkill.mockResolvedValueOnce(false); + + const result = await service.removeSkill("non-existent"); + + expect(result).toBe(false); + }); + }); + + describe("installSkill 从 ZIP 解析结果安装", () => { + it("应正确安装 parseSkillZip 返回的完整结构", async () => { + const { service, mockSkillRepo } = createTestService(); + + // 模拟 parseSkillZip 的输出结构 + const zipResult = { + skillMd: `--- +name: taobao-helper +description: 淘宝购物助手 +--- +你是一个淘宝购物助手。`, + scripts: [{ name: "taobao_extract.js", code: VALID_SKILLSCRIPT_CODE }], + references: [ + { name: "api_docs.md", content: "# API Docs\n淘宝接口文档" }, + { name: "guide.txt", content: "使用指南" }, + ], + }; + + const record = await service.installSkill(zipResult.skillMd, zipResult.scripts, zipResult.references); + + expect(record.name).toBe("taobao-helper"); + expect(record.description).toBe("淘宝购物助手"); + expect(record.prompt).toBe("你是一个淘宝购物助手。"); + expect(record.toolNames).toEqual(["test-tool"]); // 脚本名称从 ==SkillScript== metadata 中解析 + expect(record.referenceNames).toEqual(["api_docs.md", "guide.txt"]); + + // 验证 saveSkill 调用参数 + expect(mockSkillRepo.saveSkill).toHaveBeenCalledTimes(1); + const [savedRecord, savedScripts, savedRefs] = mockSkillRepo.saveSkill.mock.calls[0]; + expect(savedRecord.name).toBe("taobao-helper"); + expect(savedScripts).toHaveLength(1); + expect(savedScripts[0].name).toBe("test-tool"); + expect(savedRefs).toHaveLength(2); + expect(savedRefs[0].name).toBe("api_docs.md"); + expect(savedRefs[1].content).toBe("使用指南"); + + // 验证 skillCache 更新 + expect((service as any).skillCache.has("taobao-helper")).toBe(true); + }); + + it("ZIP 结果中多个脚本应全部安装", async () => { + const { service, mockSkillRepo } = createTestService(); + + const anotherToolCode = `// ==SkillScript== +// @name another-tool +// @description Another tool +// @param {string} query - Search query +// ==/SkillScript== +return query;`; + + const record = await service.installSkill( + `---\nname: multi-tool\ndescription: Multi tools skill\n---\nMulti tool prompt.`, + [ + { name: "tool1.js", code: VALID_SKILLSCRIPT_CODE }, + { name: "tool2.js", code: anotherToolCode }, + ], + [] + ); + + expect(record.toolNames).toHaveLength(2); + expect(record.toolNames).toContain("test-tool"); + expect(record.toolNames).toContain("another-tool"); + + const savedScripts = mockSkillRepo.saveSkill.mock.calls[0][1]; + expect(savedScripts).toHaveLength(2); + }); + + it("ZIP 结果无脚本无参考资料时应正常安装", async () => { + const { service } = createTestService(); + + const record = await service.installSkill( + `---\nname: simple-zip\ndescription: Simple\n---\nSimple prompt.`, + [], + [] + ); + + expect(record.name).toBe("simple-zip"); + expect(record.toolNames).toEqual([]); + expect(record.referenceNames).toEqual([]); + }); + }); +}); diff --git a/src/app/service/agent/service_worker/skill_service.ts b/src/app/service/agent/service_worker/skill_service.ts new file mode 100644 index 000000000..15463a60a --- /dev/null +++ b/src/app/service/agent/service_worker/skill_service.ts @@ -0,0 +1,409 @@ +import type { MessageSend } from "@Packages/message/types"; +import type { + SkillApiRequest, + SkillMetadata, + SkillRecord, + SkillScriptRecord, + SkillSummary, + ToolDefinition, +} from "@App/app/service/agent/core/types"; +import { SkillRepo } from "@App/app/repo/skill_repo"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; +import { parseSkillMd, parseSkillZip } from "@App/pkg/utils/skill"; +import { SkillScriptExecutor } from "@App/app/service/agent/core/skill_script_executor"; +import { CACHE_KEY_SKILL_INSTALL } from "@App/app/cache_key"; +import { SKILL_SUFFIX_HEADER } from "@App/app/service/agent/core/system_prompt"; +import { cacheInstance } from "@App/app/cache"; +import type { ToolExecutor } from "@App/app/service/agent/core/tool_registry"; +import type { ResourceService } from "@App/app/service/service_worker/resource"; + +export class SkillService { + // 已加载的 Skill 缓存 + skillCache = new Map(); + skillRepo: SkillRepo; + + constructor( + private sender: MessageSend, + private resourceService: ResourceService | undefined, + skillRepo?: SkillRepo + ) { + this.skillRepo = skillRepo ?? new SkillRepo(); + } + + // 创建 require 资源加载器,从 ResourceDAO 缓存中读取已下载的资源内容 + private createRequireLoader(): ((url: string) => Promise) | undefined { + if (!this.resourceService) return undefined; + const rs = this.resourceService; + return async (url: string) => { + const res = await rs.getResource("skillscript-require", url, "require", false); + return res?.content as string | undefined; + }; + } + + // ---- Skill 管理 ---- + + // 从 OPFS 加载所有 Skill 到缓存 + async loadSkills() { + try { + const summaries = await this.skillRepo.listSkills(); + for (const summary of summaries) { + const record = await this.skillRepo.getSkill(summary.name); + if (record) { + // 从 registry 同步 enabled 状态到缓存 + if (summary.enabled !== undefined) { + record.enabled = summary.enabled; + } + this.skillCache.set(record.name, record); + } + } + } catch { + // OPFS 可能不可用,静默忽略 + } + } + + // 安装 Skill + async installSkill( + skillMd: string, + scripts?: Array<{ name: string; code: string }>, + references?: Array<{ name: string; content: string }> + ): Promise { + const parsed = parseSkillMd(skillMd); + if (!parsed) { + throw new Error("Invalid SKILL.md: missing or malformed frontmatter"); + } + + // 解析 SkillScript 脚本 + const toolRecords: SkillScriptRecord[] = []; + const toolNames: string[] = []; + if (scripts) { + for (const script of scripts) { + const metadata = parseSkillScriptMetadata(script.code); + if (!metadata) { + throw new Error(`Invalid SkillScript "${script.name}": missing ==SkillScript== header`); + } + // 下载并缓存 @require 资源 + if (metadata.requires.length > 0 && this.resourceService) { + const dummyUuid = "skillscript-require"; + await Promise.all( + metadata.requires.map((url) => this.resourceService!.getResource(dummyUuid, url, "require", true)) + ); + } + toolNames.push(metadata.name); + const now = Date.now(); + toolRecords.push({ + id: uuidv4(), + name: metadata.name, + description: metadata.description, + params: metadata.params, + grants: metadata.grants, + requires: metadata.requires.length > 0 ? metadata.requires : undefined, + timeout: metadata.timeout, + code: script.code, + installtime: now, + updatetime: now, + }); + } + } + + const referenceNames = references?.map((r) => r.name) || []; + + const now = Date.now(); + const existing = await this.skillRepo.getSkill(parsed.metadata.name); + const record: SkillRecord = { + name: parsed.metadata.name, + description: parsed.metadata.description, + toolNames, + referenceNames, + prompt: parsed.prompt, + ...(parsed.metadata.config ? { config: parsed.metadata.config } : {}), + installtime: existing?.installtime || now, + updatetime: now, + }; + + const skillRefs = references?.map((r) => ({ name: r.name, content: r.content })); + await this.skillRepo.saveSkill(record, toolRecords, skillRefs); + this.skillCache.set(record.name, record); + + return record; + } + + // 卸载 Skill + async removeSkill(name: string): Promise { + const removed = await this.skillRepo.removeSkill(name); + if (removed) { + this.skillCache.delete(name); + } + return removed; + } + + // 刷新单个 Skill 缓存(从 OPFS 重新加载) + async refreshSkill(name: string): Promise { + const record = await this.skillRepo.getSkill(name); + if (record) { + this.skillCache.set(record.name, record); + return true; + } + this.skillCache.delete(name); + return false; + } + + // 启用/禁用 Skill + async setSkillEnabled(name: string, enabled: boolean): Promise { + return this.skillRepo.setSkillEnabled(name, enabled); + } + + // 缓存 Skill ZIP 数据,返回 uuid,供安装页面获取 + async prepareSkillInstall(zipBase64: string): Promise { + const uuid = uuidv4(); + await cacheInstance.set(CACHE_KEY_SKILL_INSTALL + uuid, zipBase64); + return uuid; + } + + // 获取缓存的 Skill ZIP 数据并解析 + async getSkillInstallData(uuid: string): Promise<{ + skillMd: string; + metadata: SkillMetadata; + prompt: string; + scripts: Array<{ name: string; code: string }>; + references: Array<{ name: string; content: string }>; + isUpdate: boolean; + }> { + const zipBase64 = await cacheInstance.get(CACHE_KEY_SKILL_INSTALL + uuid); + if (!zipBase64) { + throw new Error("Skill install data not found or expired"); + } + // base64 → ArrayBuffer + const binaryStr = atob(zipBase64); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + const buffer = bytes.buffer; + + const result = await parseSkillZip(buffer); + const parsed = parseSkillMd(result.skillMd); + if (!parsed) { + throw new Error("Invalid SKILL.md format in ZIP"); + } + // 检查是否为更新 + const existing = await this.skillRepo.getSkill(parsed.metadata.name); + return { + skillMd: result.skillMd, + metadata: parsed.metadata, + prompt: parsed.prompt, + scripts: result.scripts, + references: result.references, + isUpdate: !!existing, + }; + } + + // Skill 安装页面确认安装 + async completeSkillInstall(uuid: string): Promise { + const data = await this.getSkillInstallData(uuid); + const record = await this.installSkill(data.skillMd, data.scripts, data.references); + await cacheInstance.del(CACHE_KEY_SKILL_INSTALL + uuid); + return record; + } + + // Skill 安装页面取消 + async cancelSkillInstall(uuid: string): Promise { + await cacheInstance.del(CACHE_KEY_SKILL_INSTALL + uuid); + } + + // 处理 CAT.agent.skills API 请求 + async handleSkillsApi(request: SkillApiRequest): Promise { + switch (request.action) { + case "list": + return this.skillRepo.listSkills(); + case "get": + return this.skillRepo.getSkill(request.name); + case "install": + return this.installSkill(request.skillMd, request.scripts, request.references); + case "remove": + return this.removeSkill(request.name); + case "call": { + const { skillName, scriptName, params } = request; + const skillRecord = await this.skillRepo.getSkill(skillName); + if (!skillRecord) { + throw new Error(`Skill "${skillName}" not found`); + } + const scripts = await this.skillRepo.getSkillScripts(skillName); + const script = scripts.find((s) => s.name === scriptName); + if (!script) { + throw new Error(`Script "${scriptName}" not found in skill "${skillName}"`); + } + const configValues = skillRecord.config ? await this.skillRepo.getConfigValues(skillName) : undefined; + const executor = new SkillScriptExecutor(script, this.sender, this.createRequireLoader(), configValues); + return executor.execute(params || {}); + } + default: + throw new Error(`Unknown skills action: ${(request as any).action}`); + } + } + + // 解析对话关联的 skills,返回 system prompt 附加内容和 meta-tool 定义 + // 两层渐进加载:1) system prompt 只注入摘要 2) load_skill 按需加载完整提示词及脚本描述 + resolveSkills(skills?: "auto" | string[]): { + promptSuffix: string; + metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>; + } { + if (!skills) { + return { promptSuffix: "", metaTools: [] }; + } + + // 确定要加载的 skill 列表 + let skillRecords: SkillRecord[]; + if (skills === "auto") { + // auto 模式只加载已启用的 skill(enabled 为 undefined 视为启用) + skillRecords = Array.from(this.skillCache.values()).filter((r) => r.enabled !== false); + } else { + // 显式指定名称时不过滤 enabled 状态 + skillRecords = skills.map((name) => this.skillCache.get(name)).filter((r): r is SkillRecord => r != null); + } + + if (skillRecords.length === 0) { + return { promptSuffix: "", metaTools: [] }; + } + + // 构建 prompt 后缀:只包含 name + description 摘要 + const promptParts: string[] = [SKILL_SUFFIX_HEADER]; + + // 检查是否有任何参考资料 + let hasReferences = false; + + for (const skill of skillRecords) { + const toolHint = skill.toolNames.length > 0 ? ` (scripts: ${skill.toolNames.join(", ")})` : ""; + const refHint = skill.referenceNames.length > 0 ? ` [has references]` : ""; + promptParts.push(`- **${skill.name}**: ${skill.description || "(no description)"}${toolHint}${refHint}`); + if (skill.referenceNames.length > 0) hasReferences = true; + } + + // 构建 meta-tools + const metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }> = []; + + // 已加载的 skill 名,避免重复加载 + const loadedSkills = new Set(); + + // load_skill — 始终注册 + metaTools.push({ + definition: { + name: "load_skill", + description: + "Load a skill's full instructions. MUST be called before using any skill. Returns the skill's detailed prompt and a description of available scripts that can be executed via `execute_skill_script`.", + parameters: { + type: "object", + properties: { + skill_name: { type: "string", description: "Name of the skill to load" }, + }, + required: ["skill_name"], + }, + }, + executor: { + execute: async (args: Record) => { + const skillName = args.skill_name as string; + const record = this.skillCache.get(skillName); + if (!record) { + throw new Error(`Skill "${skillName}" not found`); + } + if (loadedSkills.has(skillName)) { + return record.prompt; + } + loadedSkills.add(skillName); + // 拼接脚本描述到 prompt(供 LLM 了解可用脚本及参数) + let prompt = record.prompt; + if (record.toolNames.length > 0) { + const toolRecords = await this.skillRepo.getSkillScripts(skillName); + if (toolRecords.length > 0) { + prompt += "\n\n## Available Scripts\n\nUse `execute_skill_script` to run these scripts:\n"; + for (const tool of toolRecords) { + prompt += `\n### ${tool.name}\n${tool.description}\n`; + if (tool.params.length > 0) { + prompt += "\nParameters:\n"; + for (const p of tool.params) { + const req = p.required ? " (required)" : ""; + const enumStr = p.enum ? ` [${p.enum.join(", ")}]` : ""; + prompt += `- \`${p.name}\` (${p.type}${enumStr})${req}: ${p.description}\n`; + } + } + } + } + } + return prompt; + }, + }, + }); + + // execute_skill_script — 始终注册 + metaTools.push({ + definition: { + name: "execute_skill_script", + description: "Execute a script belonging to a loaded skill. The skill must be loaded first via `load_skill`.", + parameters: { + type: "object", + properties: { + skill: { type: "string", description: "Name of the skill that owns the script" }, + script: { type: "string", description: "Name of the script to execute" }, + params: { + type: "object", + description: "Parameters to pass to the script (as defined in the script's metadata)", + }, + }, + required: ["skill", "script"], + }, + }, + executor: { + execute: async (args: Record) => { + const skillName = args.skill as string; + const scriptName = args.script as string; + const params = (args.params || {}) as Record; + if (!loadedSkills.has(skillName)) { + throw new Error(`Skill "${skillName}" is not loaded. Call load_skill first.`); + } + const toolRecords = await this.skillRepo.getSkillScripts(skillName); + const scriptRecord = toolRecords.find((t) => t.name === scriptName); + if (!scriptRecord) { + throw new Error(`Script "${scriptName}" not found in skill "${skillName}"`); + } + const configValues = this.skillCache.get(skillName)?.config + ? await this.skillRepo.getConfigValues(skillName) + : undefined; + const executor = new SkillScriptExecutor(scriptRecord, this.sender, this.createRequireLoader(), configValues); + return executor.execute(params); + }, + }, + }); + + // read_reference — 有参考资料时才注册 + if (hasReferences) { + metaTools.push({ + definition: { + name: "read_reference", + description: + "Read a reference document belonging to a skill (e.g. API docs, examples). The skill must be loaded first via `load_skill`.", + parameters: { + type: "object", + properties: { + skill_name: { type: "string", description: "Name of the skill that owns the reference" }, + reference_name: { type: "string", description: "Name of the reference document to read" }, + }, + required: ["skill_name", "reference_name"], + }, + }, + executor: { + execute: async (args: Record) => { + const skillName = args.skill_name as string; + const refName = args.reference_name as string; + const ref = await this.skillRepo.getReference(skillName, refName); + if (!ref) { + throw new Error(`Reference "${refName}" not found in skill "${skillName}"`); + } + return ref.content; + }, + }, + }); + } + + return { promptSuffix: promptParts.join("\n"), metaTools }; + } +} diff --git a/src/app/service/agent/service_worker/sub_agent_service.ts b/src/app/service/agent/service_worker/sub_agent_service.ts new file mode 100644 index 000000000..33891a5f4 --- /dev/null +++ b/src/app/service/agent/service_worker/sub_agent_service.ts @@ -0,0 +1,266 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { + AgentModelConfig, + ChatRequest, + ChatStreamEvent, + SubAgentMessage, +} from "@App/app/service/agent/core/types"; +import type { ToolRegistry } from "@App/app/service/agent/core/tool_registry"; +import type { SubAgentRunOptions, SubAgentRunResult } from "@App/app/service/agent/core/tools/sub_agent"; +import { resolveSubAgentType, getExcludeToolsForType } from "@App/app/service/agent/core/sub_agent_types"; +import { buildSubAgentSystemPrompt } from "@App/app/service/agent/core/system_prompt"; + +/** 子代理上下文条目 */ +interface SubAgentContextEntry { + agentId: string; + typeName: string; + description: string; + messages: ChatRequest["messages"]; + status: "completed" | "error"; + result?: string; +} + +/** 供 SubAgentService 调用的 orchestrator 能力 */ +export interface SubAgentOrchestrator { + callLLMWithToolLoop(params: { + model: AgentModelConfig; + messages: ChatRequest["messages"]; + maxIterations: number; + sendEvent: (event: ChatStreamEvent) => void; + signal: AbortSignal; + scriptToolCallback: null; + excludeTools?: string[]; + cache?: boolean; + }): Promise; +} + +export class SubAgentService { + // 子代理上下文缓存,按父对话 ID 分组,对话结束时清理 + private subAgentContexts = new Map>(); + + constructor( + private toolRegistry: ToolRegistry, + private orchestrator: SubAgentOrchestrator + ) {} + + // 子代理公共编排层:处理 type 解析、resume 路由 + async runSubAgent(params: { + options: SubAgentRunOptions; + model: AgentModelConfig; + parentConversationId: string; + sendEvent: (event: ChatStreamEvent) => void; + signal: AbortSignal; + }): Promise { + const { options, model, parentConversationId, sendEvent, signal } = params; + const typeConfig = resolveSubAgentType(options.type); + + // 获取所有已注册的工具名,计算排除列表 + const allToolNames = this.toolRegistry.getDefinitions().map((d) => d.name); + const excludeTools = getExcludeToolsForType(typeConfig, allToolNames); + + // resume 模式:延续已有子代理 + if (options.to) { + const contextMap = this.subAgentContexts.get(parentConversationId); + const ctx = contextMap?.get(options.to); + if (!ctx) { + return { + agentId: options.to, + result: `Error: Sub-agent "${options.to}" not found. It may have been cleaned up when the conversation ended.`, + }; + } + + // 追加新的 user message 到已有上下文 + ctx.messages.push({ role: "user", content: options.prompt }); + ctx.status = "completed"; // 重置,将由 core 更新 + + const { + result, + details, + usage: subUsage, + } = await this.runSubAgentCore({ + messages: ctx.messages, + model, + excludeTools, + maxIterations: typeConfig.maxIterations, + sendEvent, + signal, + }); + + // 更新缓存 + ctx.result = result; + ctx.status = "completed"; + + return { + agentId: options.to, + result, + details: { + agentId: options.to, + description: ctx.description, + subAgentType: ctx.typeName, + messages: details, + usage: subUsage, + }, + }; + } + + // 新建模式 + const agentId = uuidv4(); + + // 构建子代理专用 system prompt + const availableToolNames = allToolNames.filter((n) => !new Set(excludeTools).has(n)); + const systemContent = buildSubAgentSystemPrompt(typeConfig, availableToolNames); + const messages: ChatRequest["messages"] = [ + { role: "system", content: systemContent }, + { role: "user", content: options.prompt }, + ]; + + const { + result, + details, + usage: subUsage, + } = await this.runSubAgentCore({ + messages, + model, + excludeTools, + maxIterations: typeConfig.maxIterations, + sendEvent, + signal, + }); + + // 保存子代理上下文(用于延续) + if (!this.subAgentContexts.has(parentConversationId)) { + this.subAgentContexts.set(parentConversationId, new Map()); + } + const contextMap = this.subAgentContexts.get(parentConversationId)!; + // 限制每个对话最多缓存 10 个子代理上下文,LRU 淘汰 + if (contextMap.size >= 10) { + const oldestKey = contextMap.keys().next().value; + if (oldestKey) contextMap.delete(oldestKey); + } + contextMap.set(agentId, { + agentId, + typeName: typeConfig.name, + description: options.description, + messages, + status: "completed", + result, + }); + + return { + agentId, + result, + details: { + agentId, + description: options.description, + subAgentType: typeConfig.name, + messages: details, + usage: subUsage, + }, + }; + } + + // 子代理核心执行层 + private async runSubAgentCore(params: { + messages: ChatRequest["messages"]; + model: AgentModelConfig; + excludeTools: string[]; + maxIterations: number; + sendEvent: (event: ChatStreamEvent) => void; + signal: AbortSignal; + }): Promise<{ + result: string; + details: SubAgentMessage[]; + usage: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + }; + }> { + let resultContent = ""; + // 收集子代理执行详情用于持久化 + const details: SubAgentMessage[] = []; + let currentMsg: SubAgentMessage = { content: "", toolCalls: [] }; + // 累计 usage + const subUsage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; + + const subSendEvent = (event: ChatStreamEvent) => { + // 转发事件给父代理 + params.sendEvent(event); + // 收集执行详情 + switch (event.type) { + case "content_delta": + resultContent += event.delta; + currentMsg.content += event.delta; + break; + case "thinking_delta": + currentMsg.thinking = (currentMsg.thinking || "") + event.delta; + break; + case "tool_call_start": + currentMsg.toolCalls.push({ + ...event.toolCall, + arguments: event.toolCall.arguments || "", + status: "running", + }); + break; + case "tool_call_delta": + if (currentMsg.toolCalls.length) { + currentMsg.toolCalls[currentMsg.toolCalls.length - 1].arguments += event.delta; + } + break; + case "tool_call_complete": { + const tc = currentMsg.toolCalls.find((t) => t.id === event.id); + if (tc) { + tc.status = "completed"; + tc.result = event.result; + tc.attachments = event.attachments; + } + break; + } + case "new_message": + // 新一轮开始,归档当前消息 + resultContent = ""; + if (currentMsg.content || currentMsg.thinking || currentMsg.toolCalls.length > 0) { + details.push(currentMsg); + } + currentMsg = { content: "", toolCalls: [] }; + break; + case "done": + if (event.usage) { + subUsage.inputTokens += event.usage.inputTokens; + subUsage.outputTokens += event.usage.outputTokens; + subUsage.cacheCreationInputTokens += event.usage.cacheCreationInputTokens || 0; + subUsage.cacheReadInputTokens += event.usage.cacheReadInputTokens || 0; + } + break; + } + }; + + await this.orchestrator.callLLMWithToolLoop({ + model: params.model, + messages: params.messages, + maxIterations: params.maxIterations, + sendEvent: subSendEvent, + signal: params.signal, + scriptToolCallback: null, + excludeTools: params.excludeTools, + cache: false, + }); + + // 归档最后一轮消息 + if (currentMsg.content || currentMsg.thinking || currentMsg.toolCalls.length > 0) { + details.push(currentMsg); + } + + return { + result: resultContent || "(sub-agent produced no output)", + details, + usage: subUsage, + }; + } + + /** 清理某对话的所有子代理上下文 */ + cleanup(parentConversationId: string): void { + this.subAgentContexts.delete(parentConversationId); + } +} diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts new file mode 100644 index 000000000..551534b9e --- /dev/null +++ b/src/app/service/agent/service_worker/task_service.ts @@ -0,0 +1,298 @@ +import type { MessageSend } from "@Packages/message/types"; +import type { + AgentModelConfig, + AgentTask, + AgentTaskApiRequest, + AgentTaskTrigger, + ChatRequest, + ChatStreamEvent, + Conversation, +} from "@App/app/service/agent/core/types"; +import type { ScriptToolCallback, ToolRegistry } from "@App/app/service/agent/core/tool_registry"; +import type { SkillService } from "./skill_service"; +import type { AgentTaskRepo, AgentTaskRunRepo } from "@App/app/repo/agent_task"; +import type { AgentTaskScheduler } from "@App/app/service/agent/core/task_scheduler"; +import type { AgentChatRepo } from "@App/app/repo/agent_chat"; +import { buildSystemPrompt } from "@App/app/service/agent/core/system_prompt"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { nextTimeInfo } from "@App/pkg/utils/cron"; +import { InfoNotification } from "@App/app/service/service_worker/utils"; +import { sendMessage } from "@Packages/message/client"; + +/** 供 TaskService 调用的 orchestrator 能力 */ +export interface TaskOrchestrator { + getModel(modelId?: string): Promise; + callLLMWithToolLoop(params: { + model: AgentModelConfig; + messages: ChatRequest["messages"]; + maxIterations: number; + sendEvent: (event: ChatStreamEvent) => void; + signal: AbortSignal; + scriptToolCallback: ScriptToolCallback | null; + conversationId: string; + }): Promise; +} + +export class AgentTaskService { + // 循环依赖通过 setScheduler 延迟注入 + private taskScheduler?: AgentTaskScheduler; + + constructor( + private sender: MessageSend, + private repo: AgentChatRepo, + private toolRegistry: ToolRegistry, + private skillService: SkillService, + private orchestrator: TaskOrchestrator, + private taskRepo: AgentTaskRepo, + private taskRunRepo: AgentTaskRunRepo + ) {} + + // 延迟注入 scheduler(避免循环依赖:AgentTaskScheduler ↔ AgentTaskService) + setScheduler(scheduler: AgentTaskScheduler) { + this.taskScheduler = scheduler; + } + + // internal 模式定时任务执行:构建对话并调用 LLM + async executeInternalTask( + task: AgentTask + ): Promise<{ conversationId: string; usage?: { inputTokens: number; outputTokens: number } }> { + const model = await this.orchestrator.getModel(task.modelId); + + // 解析 Skills + const { promptSuffix, metaTools } = this.skillService.resolveSkills(task.skills); + + // 临时注册 skill meta-tools + const registeredMetaToolNames: string[] = []; + for (const mt of metaTools) { + this.toolRegistry.registerBuiltin(mt.definition, mt.executor); + registeredMetaToolNames.push(mt.definition.name); + } + + try { + let conversationId: string; + const messages: ChatRequest["messages"] = []; + + if (task.conversationId) { + // 续接已有对话 + conversationId = task.conversationId; + const conv = await this.getConversation(conversationId); + + const systemContent = buildSystemPrompt({ + userSystem: conv?.system, + skillSuffix: promptSuffix, + }); + messages.push({ role: "system", content: systemContent }); + + // 加载历史消息 + if (conv) { + const existingMessages = await this.repo.getMessages(conversationId); + + // 预加载之前已加载的 skill 的工具 + if (metaTools.length > 0) { + const loadSkillMeta = metaTools.find((mt) => mt.definition.name === "load_skill"); + if (loadSkillMeta) { + for (const msg of existingMessages) { + if (msg.role === "assistant" && msg.toolCalls) { + for (const tc of msg.toolCalls) { + if (tc.name === "load_skill") { + try { + const args = JSON.parse(tc.arguments || "{}"); + if (args.skill_name) { + await loadSkillMeta.executor.execute({ skill_name: args.skill_name }); + } + } catch { + // 跳过 + } + } + } + } + } + } + } + + for (const msg of existingMessages) { + if (msg.role === "system") continue; + messages.push({ + role: msg.role, + content: msg.content, + toolCallId: msg.toolCallId, + toolCalls: msg.toolCalls, + }); + } + } + } else { + // 创建新对话 + conversationId = uuidv4(); + const conv: Conversation = { + id: conversationId, + title: task.name, + modelId: model.id, + skills: task.skills, + createtime: Date.now(), + updatetime: Date.now(), + }; + await this.repo.saveConversation(conv); + + const systemContent = buildSystemPrompt({ skillSuffix: promptSuffix }); + messages.push({ role: "system", content: systemContent }); + } + + // 添加用户消息(task.prompt) + const userContent = task.prompt || task.name; + messages.push({ role: "user", content: userContent }); + await this.repo.appendMessage({ + id: uuidv4(), + conversationId, + role: "user", + content: userContent, + createtime: Date.now(), + }); + + // 收集 usage + const totalUsage = { inputTokens: 0, outputTokens: 0 }; + const abortController = new AbortController(); + + const sendEvent = (event: ChatStreamEvent) => { + // 定时任务无 UI 连接,但需要收集 usage + if (event.type === "done" && event.usage) { + totalUsage.inputTokens += event.usage.inputTokens; + totalUsage.outputTokens += event.usage.outputTokens; + } + }; + + await this.orchestrator.callLLMWithToolLoop({ + model, + messages, + maxIterations: task.maxIterations || 10, + sendEvent, + signal: abortController.signal, + scriptToolCallback: null, + conversationId, + }); + + // 通知 + if (task.notify) { + InfoNotification(task.name, "定时任务执行完成"); + } + + return { conversationId, usage: totalUsage }; + } finally { + // 清理临时注册的 meta-tools + for (const name of registeredMetaToolNames) { + this.toolRegistry.unregisterBuiltin(name); + } + } + } + + // event 模式定时任务:通知脚本 + async emitTaskEvent(task: AgentTask): Promise { + if (!task.sourceScriptUuid) { + throw new Error("Event mode task missing sourceScriptUuid"); + } + + const trigger: AgentTaskTrigger = { + taskId: task.id, + name: task.name, + crontab: task.crontab, + triggeredAt: Date.now(), + }; + + // 通过 offscreen → sandbox → 脚本 EventEmitter 链路通知脚本 + await sendMessage(this.sender, "offscreen/runtime/emitEvent", { + uuid: task.sourceScriptUuid, + event: "agentTask", + eventId: task.id, + data: trigger, + }); + + if (task.notify) { + InfoNotification(task.name, "定时任务已触发"); + } + } + + private async getConversation(id: string): Promise { + const conversations = await this.repo.listConversations(); + return conversations.find((c) => c.id === id) || null; + } + + // 处理定时任务 CRUD 及 run 操作 + async handleAgentTask(params: AgentTaskApiRequest): Promise { + switch (params.action) { + case "list": + return this.taskRepo.listTasks(); + case "get": + return this.taskRepo.getTask(params.id); + case "create": { + const now = Date.now(); + const task: AgentTask = { + ...params.task, + id: uuidv4(), + createtime: now, + updatetime: now, + }; + // 计算 nextruntime + if (task.enabled) { + try { + const info = nextTimeInfo(task.crontab); + task.nextruntime = info.next.toMillis(); + } catch { + // cron 无效,不设置 nextruntime + } + } + await this.taskRepo.saveTask(task); + return task; + } + case "update": { + const existing = await this.taskRepo.getTask(params.id); + if (!existing) throw new Error("Task not found"); + const updated = { ...existing, ...params.task, updatetime: Date.now() }; + // 如果 crontab 或 enabled 变化,重新计算 nextruntime + if (params.task.crontab !== undefined || params.task.enabled !== undefined) { + if (updated.enabled) { + try { + const info = nextTimeInfo(updated.crontab); + updated.nextruntime = info.next.toMillis(); + } catch { + updated.nextruntime = undefined; + } + } + } + await this.taskRepo.saveTask(updated); + return updated; + } + case "delete": + await this.taskRepo.removeTask(params.id); + return true; + case "enable": { + const task = await this.taskRepo.getTask(params.id); + if (!task) throw new Error("Task not found"); + task.enabled = params.enabled; + task.updatetime = Date.now(); + if (task.enabled) { + try { + const info = nextTimeInfo(task.crontab); + task.nextruntime = info.next.toMillis(); + } catch { + task.nextruntime = undefined; + } + } + await this.taskRepo.saveTask(task); + return task; + } + case "runNow": { + const task = await this.taskRepo.getTask(params.id); + if (!task) throw new Error("Task not found"); + // 不 await,立即返回 + this.taskScheduler?.executeTask(task).catch(() => {}); + return true; + } + case "listRuns": + return this.taskRunRepo.listRuns(params.taskId, params.limit); + case "clearRuns": + await this.taskRunRepo.clearRuns(params.taskId); + return true; + default: + throw new Error(`Unknown agentTask action: ${(params as any).action}`); + } + } +} diff --git a/src/app/service/agent/service_worker/test-helpers.ts b/src/app/service/agent/service_worker/test-helpers.ts new file mode 100644 index 000000000..ff3ef4bfc --- /dev/null +++ b/src/app/service/agent/service_worker/test-helpers.ts @@ -0,0 +1,238 @@ +import { vi } from "vitest"; +import { AgentService } from "./agent"; +import type { SkillRecord, SkillScriptRecord } from "@App/app/service/agent/core/types"; + +// mock agent_chat repo 单例:让所有 import { agentChatRepo } 的子服务拿到同一个 mock 对象 +// 该对象在 createTestService() 中被重置,测试通过 mockRepo 断言 +// vi.mock 和 vi.hoisted 都会被 vitest 提升到文件顶部,确保子服务 import 前 mock 已就绪 +const { mockChatRepo } = vi.hoisted(() => ({ + mockChatRepo: {} as any, +})); + +vi.mock("@App/app/repo/agent_chat", () => ({ + AgentChatRepo: class {}, + agentChatRepo: mockChatRepo, +})); + +// mock offscreen/client — isolate: false 下会影响其他测试文件, +// extract 函数需要委托到 sender.sendMessage 以保持与其他测试的兼容性 +vi.mock("@App/app/service/offscreen/client", () => { + // 通用的 sendMessage 委托实现 + const delegateToSender = (action: string, defaultValue: any) => + vi.fn().mockImplementation(async (sender: any, data: any) => { + const res = await sender.sendMessage({ action, data }); + return res?.data ?? defaultValue; + }); + return { + createObjectURL: vi.fn().mockResolvedValue("blob:chrome-extension://test/mock-blob-url"), + executeSkillScript: vi.fn(), + extractHtmlContent: delegateToSender("offscreen/htmlExtractor/extractHtmlContent", null), + extractHtmlWithSelectors: delegateToSender("offscreen/htmlExtractor/extractHtmlWithSelectors", null), + extractBingResults: delegateToSender("offscreen/htmlExtractor/extractBingResults", []), + extractBaiduResults: delegateToSender("offscreen/htmlExtractor/extractBaiduResults", []), + extractSearchResults: delegateToSender("offscreen/htmlExtractor/extractSearchResults", []), + }; +}); + +// 创建 mock AgentService 实例 +export function createTestService() { + const mockGroup = { on: vi.fn() } as any; + const mockSender = {} as any; + + // 重置 agent_chat 单例 mock 方法(保持对象身份不变,只替换 vi.fn) + Object.assign(mockChatRepo, { + appendMessage: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockResolvedValue([]), + listConversations: vi.fn().mockResolvedValue([]), + saveConversation: vi.fn().mockResolvedValue(undefined), + saveMessages: vi.fn().mockResolvedValue(undefined), + getTasks: vi.fn().mockResolvedValue([]), + saveTasks: vi.fn().mockResolvedValue(undefined), + getAttachment: vi.fn().mockResolvedValue(null), + saveAttachment: vi.fn().mockResolvedValue(0), + }); + + const service = new AgentService(mockGroup, mockSender); + + // 替换 modelRepo(避免 chrome.storage 调用) + const mockModelRepo = { + listModels: vi + .fn() + .mockResolvedValue([ + { id: "test-openai", name: "Test", provider: "openai", apiBaseUrl: "", apiKey: "", model: "gpt-4o" }, + ]), + getModel: vi.fn().mockImplementation((id: string) => { + if (id === "test-openai") { + return Promise.resolve({ + id: "test-openai", + name: "Test", + provider: "openai", + apiBaseUrl: "", + apiKey: "", + model: "gpt-4o", + }); + } + return Promise.resolve(undefined); + }), + getDefaultModelId: vi.fn().mockResolvedValue("test-openai"), + saveModel: vi.fn().mockResolvedValue(undefined), + removeModel: vi.fn().mockResolvedValue(undefined), + setDefaultModelId: vi.fn().mockResolvedValue(undefined), + }; + (service as any).modelRepo = mockModelRepo; + + // 替换 skillRepo(避免 OPFS 调用) + const mockSkillRepo = { + listSkills: vi.fn().mockResolvedValue([]), + getSkill: vi.fn().mockResolvedValue(null), + saveSkill: vi.fn().mockResolvedValue(undefined), + removeSkill: vi.fn().mockResolvedValue(true), + getSkillScripts: vi.fn().mockResolvedValue([]), + getSkillReferences: vi.fn().mockResolvedValue([]), + getReference: vi.fn().mockResolvedValue(null), + getConfigValues: vi.fn().mockResolvedValue(undefined), + }; + (service as any).skillRepo = mockSkillRepo; + + return { service, mockRepo: mockChatRepo, mockSkillRepo, mockModelRepo }; +} + +export const VALID_SKILLSCRIPT_CODE = `// ==SkillScript== +// @name test-tool +// @description A test tool +// @param {string} input - The input +// ==/SkillScript== +module.exports = async function(params) { return params.input; }`; + +// 辅助:创建 SkillRecord +export function makeSkillRecord(overrides: Partial = {}): SkillRecord { + return { + name: "test-skill", + description: "A test skill", + toolNames: [], + referenceNames: [], + prompt: "You are a test skill assistant.", + installtime: Date.now(), + updatetime: Date.now(), + ...overrides, + }; +} + +// 辅助:创建 SkillScriptRecord +export function makeSkillScriptRecord(overrides: Partial = {}): SkillScriptRecord { + return { + id: "tool-id-1", + name: "test-script", + description: "A test skill script", + params: [{ name: "input", type: "string", description: "The input", required: true }], + grants: [], + code: "module.exports = async (p) => p.input;", + installtime: Date.now(), + updatetime: Date.now(), + ...overrides, + }; +} + +// 辅助:创建 mock sender(简单版,只收集消息) +export function createMockSender() { + const sentMessages: any[] = []; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const sender = { + isType: (type: any) => type === 1, // GetSenderType.CONNECT + getConnect: () => mockConn, + }; + return { sender, sentMessages }; +} + +// 辅助:创建 mock sender(带 message/disconnect 模拟回调) +export function createMockSenderWithCallbacks() { + const sentMessages: any[] = []; + let messageHandler: ((msg: any) => void) | null = null; + let disconnectHandler: (() => void) | null = null; + const mockConn = { + sendMessage: (msg: any) => sentMessages.push(msg), + onMessage: vi.fn((handler: any) => { + messageHandler = handler; + }), + onDisconnect: vi.fn((handler: any) => { + disconnectHandler = handler; + }), + }; + const sender = { + isType: (type: any) => type === 1, + getConnect: () => mockConn, + }; + return { + sender, + sentMessages, + simulateMessage: (msg: any) => messageHandler?.(msg), + simulateDisconnect: () => disconnectHandler?.(), + }; +} + +// 辅助:创建最简 OpenAI SSE 文本响应 +export function makeTextResponse(text: string): Response { + const encoder = new TextEncoder(); + const chunks = [ + `data: {"choices":[{"delta":{"content":"${text}"}}]}\n\n`, + `data: {"usage":{"prompt_tokens":10,"completion_tokens":5}}\n\n`, + ]; + let i = 0; + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + if (i >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: encoder.encode(chunks[i++]) }; + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + }), + }, + text: async () => "", + } as unknown as Response; +} + +// 辅助:创建 OpenAI SSE 响应(自定义 chunks) +export function makeSSEResponse(chunks: string[]): Response { + const encoder = new TextEncoder(); + let i = 0; + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + if (i >= chunks.length) return { done: true, value: undefined }; + return { done: false, value: encoder.encode(chunks[i++]) }; + }, + releaseLock: () => {}, + cancel: async () => {}, + closed: Promise.resolve(undefined), + }), + }, + text: async () => "", + } as unknown as Response; +} + +// 辅助:创建 RunningConversation 快照对象 +export function createRunningConversation(overrides: Record = {}) { + return { + conversationId: "conv-bg", + abortController: new AbortController(), + listeners: new Set(), + streamingState: { content: "", thinking: "", toolCalls: [] as any[] }, + pendingAskUser: undefined as any, + askResolvers: new Map void>(), + tasks: [] as any[], + status: "running" as string, + ...overrides, + }; +} diff --git a/src/app/service/agent/service_worker/tool_loop_orchestrator.ts b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts new file mode 100644 index 000000000..ff2b12cad --- /dev/null +++ b/src/app/service/agent/service_worker/tool_loop_orchestrator.ts @@ -0,0 +1,281 @@ +import { agentChatRepo } from "@App/app/repo/agent_chat"; +import type { ScriptToolCallback } from "@App/app/service/agent/core/tool_registry"; +import type { ToolRegistry } from "@App/app/service/agent/core/tool_registry"; +import type { + AgentModelConfig, + ChatRequest, + ChatStreamEvent, + ToolDefinition, + ToolCall, + Attachment, + SubAgentDetails, + ContentBlock, + MessageContent, +} from "@App/app/service/agent/core/types"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { getContextWindow } from "@App/app/service/agent/core/model_context"; +import { detectToolCallIssues, type ToolCallRecord } from "@App/app/service/agent/core/tool_call_guard"; +import { withRetry } from "./retry_utils"; +import type { LLMCallResult } from "./llm_client"; + +/** ToolLoopOrchestrator 所需的外部依赖(由 AgentService 注入) */ +export interface ToolLoopDeps { + // callLLM 通过 lambda 注入,确保测试 spy 可以拦截 + callLLM( + model: AgentModelConfig, + params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ): Promise; + autoCompact( + conversationId: string, + model: AgentModelConfig, + messages: ChatRequest["messages"], + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ): Promise; +} + +export class ToolLoopOrchestrator { + constructor( + private toolRegistry: ToolRegistry, + private deps: ToolLoopDeps + ) {} + + // 统一的 tool calling 循环,UI 和脚本共用 + async callLLMWithToolLoop(params: { + model: AgentModelConfig; + messages: ChatRequest["messages"]; + tools?: ToolDefinition[]; + maxIterations: number; + sendEvent: (event: ChatStreamEvent) => void; + signal: AbortSignal; + // 脚本自定义工具的回调,null 表示只用内置工具 + scriptToolCallback: ScriptToolCallback | null; + // 对话 ID,用于持久化消息(可选,UI 场景由 hooks 自行持久化) + conversationId?: string; + // 跳过内置工具,仅使用传入的 tools(ephemeral 模式) + skipBuiltinTools?: boolean; + // 排除的工具名称列表(子代理不可用 ask_user、agent) + excludeTools?: string[]; + // 是否启用 prompt caching,默认 true + cache?: boolean; + // 仅供测试注入,跳过重试延迟 + delayFn?: (ms: number, signal: AbortSignal) => Promise; + }): Promise { + const { model, messages, tools, maxIterations, sendEvent, signal, scriptToolCallback, conversationId } = params; + + const startTime = Date.now(); + let iterations = 0; + const totalUsage = { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 }; + const toolCallHistory: ToolCallRecord[] = []; + let guardStartIndex = 0; + + while (iterations < maxIterations) { + iterations++; + + // 每轮重新获取工具定义(load_skill 可能动态注册了新工具) + let allToolDefs = params.skipBuiltinTools ? tools || [] : this.toolRegistry.getDefinitions(tools); + if (params.excludeTools && params.excludeTools.length > 0) { + const excludeSet = new Set(params.excludeTools); + allToolDefs = allToolDefs.filter((t) => !excludeSet.has(t.name)); + } + + // 调用 LLM(带指数退避重试) + const result = await withRetry( + () => + this.deps.callLLM( + model, + { messages, tools: allToolDefs.length > 0 ? allToolDefs : undefined, cache: params.cache }, + sendEvent, + signal + ), + signal, + undefined, + params.delayFn + ); + + if (signal.aborted) return; + + // 累计 usage + if (result.usage) { + totalUsage.inputTokens += result.usage.inputTokens; + totalUsage.outputTokens += result.usage.outputTokens; + totalUsage.cacheCreationInputTokens += result.usage.cacheCreationInputTokens || 0; + totalUsage.cacheReadInputTokens += result.usage.cacheReadInputTokens || 0; + } + + // 自动 compact:当上下文占用超过 80% 时触发 + if (result.usage && conversationId) { + const contextWindow = getContextWindow(model); + const usageRatio = result.usage.inputTokens / contextWindow; + + if (usageRatio >= 0.8) { + await this.deps.autoCompact(conversationId, model, messages, sendEvent, signal); + } + } + + // 构建 assistant 消息的持久化内容(合并文本和生成的图片 blocks) + const buildMessageContent = (): MessageContent => { + if (result.contentBlocks && result.contentBlocks.length > 0) { + const blocks: ContentBlock[] = []; + if (result.content) blocks.push({ type: "text", text: result.content }); + blocks.push(...result.contentBlocks); + return blocks; + } + return result.content; + }; + + // 如果有 tool calls,需要执行并继续循环 + if (result.toolCalls && result.toolCalls.length > 0 && allToolDefs.length > 0) { + // 持久化 assistant 消息(含 tool calls) + if (conversationId) { + await agentChatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: buildMessageContent(), + thinking: result.thinking ? { content: result.thinking } : undefined, + toolCalls: result.toolCalls, + createtime: Date.now(), + }); + } + + // 将 assistant 消息加入上下文(带 toolCalls,供 provider 构建 tool_calls 字段) + messages.push({ role: "assistant", content: result.content || "", toolCalls: result.toolCalls }); + + // 通过 ToolRegistry 执行工具(内置工具直接执行,脚本工具回调 Sandbox) + const toolResults = await this.toolRegistry.execute(result.toolCalls, scriptToolCallback); + + // 将 tool 结果加入消息,并通知 UI 工具执行完成 + // 收集需要回写的 toolCall 元数据(附件 / 子代理详情) + const attachmentUpdates = new Map(); + const subAgentUpdates = new Map(); + + for (const tr of toolResults) { + // LLM 上下文只包含文本结果,不含附件 + messages.push({ role: "tool", content: tr.result, toolCallId: tr.id }); + // 通知 UI 工具执行完成(含附件元数据) + sendEvent({ type: "tool_call_complete", id: tr.id, result: tr.result, attachments: tr.attachments }); + + if (tr.attachments?.length) { + attachmentUpdates.set(tr.id, tr.attachments); + } + if (tr.subAgentDetails) { + subAgentUpdates.set(tr.id, tr.subAgentDetails); + } + + // 持久化 tool 结果消息 + if (conversationId) { + await agentChatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "tool", + content: tr.result, + toolCallId: tr.id, + createtime: Date.now(), + }); + } + } + + // 回写附件 / 子代理详情到 assistant 消息的 toolCalls(内存 + 持久化) + const needsUpdate = attachmentUpdates.size > 0 || subAgentUpdates.size > 0; + if (needsUpdate) { + const toolCallIds = new Set([...attachmentUpdates.keys(), ...subAgentUpdates.keys()]); + const assistantMsg = messages.find( + (m) => m.role === "assistant" && m.toolCalls?.some((tc: ToolCall) => toolCallIds.has(tc.id)) + ); + if (assistantMsg?.toolCalls) { + for (const tc of assistantMsg.toolCalls) { + const atts = attachmentUpdates.get(tc.id); + if (atts) tc.attachments = atts; + const sad = subAgentUpdates.get(tc.id); + if (sad) tc.subAgentDetails = sad; + } + // 更新持久化的 assistant 消息 + if (conversationId) { + const allMessages = await agentChatRepo.getMessages(conversationId); + for (let i = allMessages.length - 1; i >= 0; i--) { + const msg = allMessages[i]; + if (msg.role === "assistant" && msg.toolCalls?.some((tc: ToolCall) => toolCallIds.has(tc.id))) { + for (const tc of msg.toolCalls!) { + const atts = attachmentUpdates.get(tc.id); + if (atts) tc.attachments = atts; + const sad = subAgentUpdates.get(tc.id); + if (sad) tc.subAgentDetails = sad; + } + await agentChatRepo.saveMessages(conversationId, allMessages); + break; + } + } + } + } + } + + // 记录工具调用历史用于模式检测 + const resultMap = new Map(toolResults.map((r) => [r.id, r])); + for (const tc of result.toolCalls) { + const tr = resultMap.get(tc.id); + toolCallHistory.push({ + name: tc.name, + args: tc.arguments, + result: tr?.result ?? "", + iteration: iterations, + }); + } + + // 工具调用模式检测:检测重复/循环模式并注入针对性提醒 + // 每次警告后推进 startIndex,避免旧记录持续触发同一条警告 + const toolCallWarning = detectToolCallIssues(toolCallHistory, guardStartIndex); + if (toolCallWarning) { + guardStartIndex = toolCallHistory.length; + messages.push({ role: "user", content: toolCallWarning }); + sendEvent({ type: "system_warning", message: toolCallWarning }); + } + + // 通知 UI 即将开始新一轮 LLM 调用,创建新的 assistant 消息 + sendEvent({ type: "new_message" }); + + // 继续循环 + continue; + } + + // 没有 tool calls,对话结束 + const durationMs = Date.now() - startTime; + if (conversationId) { + await agentChatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: buildMessageContent(), + thinking: result.thinking ? { content: result.thinking } : undefined, + usage: totalUsage, + durationMs, + createtime: Date.now(), + }); + } + + // 发送 done 事件 + sendEvent({ type: "done", usage: totalUsage, durationMs }); + return; + } + + // 超过最大迭代次数 + const maxIterMsg = `Tool calling loop exceeded maximum iterations (${maxIterations})`; + if (conversationId) { + await agentChatRepo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: maxIterMsg, + createtime: Date.now(), + }); + } + sendEvent({ + type: "error", + message: maxIterMsg, + errorCode: "max_iterations", + }); + } +} diff --git a/src/app/service/content/gm_api/cat_agent.test.ts b/src/app/service/content/gm_api/cat_agent.test.ts new file mode 100644 index 000000000..47fce7225 --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -0,0 +1,517 @@ +import { describe, expect, it, vi } from "vitest"; +import { ConversationInstance } from "./cat_agent"; +import type { Conversation, StreamChunk } from "@App/app/service/agent/core/types"; +import type { MessageConnect } from "@Packages/message/types"; + +function mockConversation(overrides?: Partial): Conversation { + return { + id: "test-conv-id", + title: "Test", + modelId: "gpt-4", + createtime: Date.now(), + updatetime: Date.now(), + ...overrides, + }; +} + +// 创建模拟的 MessageConnect,模拟 LLM 正常回复 +function mockConnect(): MessageConnect { + const conn: MessageConnect = { + onMessage(cb: (msg: any) => void) { + setTimeout(() => { + cb({ action: "event", data: { type: "content_delta", delta: "LLM reply" } }); + cb({ action: "event", data: { type: "done", usage: { inputTokens: 10, outputTokens: 5 } } }); + }, 10); + }, + onDisconnect() {}, + sendMessage() {}, + disconnect() {}, + }; + return conn; +} + +function createInstance(commands?: Record Promise>) { + const gmSendMessage = vi.fn().mockResolvedValue(undefined); + const gmConnect = vi.fn().mockResolvedValue(mockConnect()); + + const instance = new ConversationInstance( + mockConversation(), + gmSendMessage, + gmConnect, + "test-script-uuid", + 20, + undefined, // initialTools + commands + ); + + return { instance, gmSendMessage, gmConnect }; +} + +describe("ConversationInstance 命令机制", () => { + it("内置 /new 命令清空消息历史", async () => { + const { instance, gmSendMessage } = createInstance(); + + const result = await instance.chat("/new"); + + expect(result.command).toBe(true); + expect(result.content).toBe("对话已清空"); + // 应该调用了 clearMessages + expect(gmSendMessage).toHaveBeenCalledWith("CAT_agentConversation", [ + expect.objectContaining({ action: "clearMessages", conversationId: "test-conv-id" }), + ]); + }); + + it("自定义命令正确拦截并返回结果", async () => { + const { instance, gmConnect } = createInstance({ + "/search": async (args) => { + return `搜索结果: ${args}`; + }, + }); + + const result = await instance.chat("/search hello world"); + + expect(result.command).toBe(true); + expect(result.content).toBe("搜索结果: hello world"); + // 不应建立 LLM 连接 + expect(gmConnect).not.toHaveBeenCalled(); + }); + + it("未注册的 /xxx 命令正常发送给 LLM", async () => { + const { instance, gmConnect } = createInstance(); + + const result = await instance.chat("/unknown command"); + + expect(result.command).toBeUndefined(); + expect(result.content).toBe("LLM reply"); + // 应该建立了 LLM 连接 + expect(gmConnect).toHaveBeenCalled(); + }); + + it("普通消息正常发送给 LLM", async () => { + const { instance, gmConnect } = createInstance(); + + const result = await instance.chat("你好"); + + expect(result.command).toBeUndefined(); + expect(result.content).toBe("LLM reply"); + expect(gmConnect).toHaveBeenCalled(); + }); + + it("chatStream 命令拦截返回正确的 chunk 序列", async () => { + const { instance, gmConnect } = createInstance({ + "/test": async () => "测试结果", + }); + + const stream = await instance.chatStream("/test"); + const chunks: StreamChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks).toHaveLength(1); + expect(chunks[0].type).toBe("done"); + expect(chunks[0].content).toBe("测试结果"); + expect(chunks[0].command).toBe(true); + // 不应建立 LLM 连接 + expect(gmConnect).not.toHaveBeenCalled(); + }); + + it("脚本覆盖内置 /new 命令", async () => { + const customNewHandler = vi.fn().mockResolvedValue("自定义清空逻辑"); + const { instance, gmSendMessage } = createInstance({ + "/new": customNewHandler, + }); + + const result = await instance.chat("/new"); + + expect(result.command).toBe(true); + expect(result.content).toBe("自定义清空逻辑"); + expect(customNewHandler).toHaveBeenCalledWith("", instance); + // 自定义处理器不会自动调用 clearMessages + expect(gmSendMessage).not.toHaveBeenCalled(); + }); + + it("命令处理器返回 void 时 content 为空字符串", async () => { + const { instance } = createInstance({ + "/silent": async () => { + // 不返回值 + }, + }); + + const result = await instance.chat("/silent"); + + expect(result.command).toBe(true); + expect(result.content).toBe(""); + }); + + it("命令参数正确传递", async () => { + const handler = vi.fn().mockResolvedValue("ok"); + const { instance } = createInstance({ + "/cmd": handler, + }); + + await instance.chat("/cmd arg1 arg2 "); + + expect(handler).toHaveBeenCalledWith("arg1 arg2", instance); + }); + + it("无参数命令正确解析", async () => { + const handler = vi.fn().mockResolvedValue("ok"); + const { instance } = createInstance({ + "/reset": handler, + }); + + await instance.chat("/reset"); + + expect(handler).toHaveBeenCalledWith("", instance); + }); +}); + +// ---- Ephemeral 会话测试 ---- + +function createEphemeralInstance(options?: { + system?: string; + tools?: Array<{ + name: string; + description: string; + parameters: Record; + handler: (args: Record) => Promise; + }>; +}) { + const gmSendMessage = vi.fn().mockResolvedValue(undefined); + const gmConnect = vi.fn().mockResolvedValue(mockConnect()); + + const instance = new ConversationInstance( + mockConversation({ modelId: "test-model" }), + gmSendMessage, + gmConnect, + "test-script-uuid", + 20, + options?.tools, + undefined, // commands + true, // ephemeral + options?.system + ); + + return { instance, gmSendMessage, gmConnect }; +} + +describe("ConversationInstance ephemeral 模式", () => { + it("chat 时传递 ephemeral 参数给 SW", async () => { + const { instance, gmConnect } = createEphemeralInstance({ system: "你是助手" }); + + await instance.chat("你好"); + + expect(gmConnect).toHaveBeenCalledTimes(1); + const connectParams = gmConnect.mock.calls[0][1][0]; + expect(connectParams.ephemeral).toBe(true); + expect(connectParams.system).toBe("你是助手"); + expect(connectParams.modelId).toBe("test-model"); + // messages 应包含 user message + expect(connectParams.messages).toEqual(expect.arrayContaining([{ role: "user", content: "你好" }])); + }); + + it("chat 后 assistant 消息追加到内存历史", async () => { + const { instance } = createEphemeralInstance(); + + await instance.chat("你好"); + + const messages = await instance.getMessages(); + // 应有 user + assistant + expect(messages.length).toBeGreaterThanOrEqual(2); + expect(messages[0].role).toBe("user"); + expect(messages[0].content).toBe("你好"); + // 最后一条应是 assistant + const lastMsg = messages[messages.length - 1]; + expect(lastMsg.role).toBe("assistant"); + expect(lastMsg.content).toBe("LLM reply"); + }); + + it("多轮对话正确累积消息历史", async () => { + const { instance, gmConnect } = createEphemeralInstance(); + + await instance.chat("第一条"); + await instance.chat("第二条"); + + // 第二次 connect 时 messages 应包含前一轮的历史 + const secondCallParams = gmConnect.mock.calls[1][1][0]; + const msgs = secondCallParams.messages; + // 包含:user("第一条") + assistant("LLM reply") + assistant("LLM reply")(final) + user("第二条") + expect(msgs.length).toBeGreaterThanOrEqual(3); + expect(msgs[0]).toEqual({ role: "user", content: "第一条" }); + // 最后一条在 messages 数组中是 user("第二条"),因为 user message 在 connect 前追加 + // assistant 回复在 processChat 之后才追加,所以第二次 connect 时的 messages 最后一条是 user + const userMsgs = msgs.filter((m: any) => m.role === "user"); + expect(userMsgs).toHaveLength(2); + expect(userMsgs[0].content).toBe("第一条"); + expect(userMsgs[1].content).toBe("第二条"); + // 应有第一轮的 assistant 回复 + const assistantMsgs = msgs.filter((m: any) => m.role === "assistant"); + expect(assistantMsgs.length).toBeGreaterThanOrEqual(1); + expect(assistantMsgs[0].content).toBe("LLM reply"); + }); + + it("getMessages 返回内存历史(不调用 SW)", async () => { + const { instance, gmSendMessage } = createEphemeralInstance(); + + await instance.chat("测试"); + + const messages = await instance.getMessages(); + // 不应调用 SW 的 getMessages + expect(gmSendMessage).not.toHaveBeenCalledWith( + "CAT_agentConversation", + expect.arrayContaining([expect.objectContaining({ action: "getMessages" })]) + ); + // 消息应包含 conversationId 和 id + expect(messages[0].conversationId).toBe("test-conv-id"); + expect(messages[0].id).toMatch(/^ephemeral-/); + }); + + it("clear 清空内存历史(不调用 SW)", async () => { + const { instance, gmSendMessage } = createEphemeralInstance(); + + await instance.chat("测试"); + expect((await instance.getMessages()).length).toBeGreaterThan(0); + + await instance.clear(); + + const messages = await instance.getMessages(); + expect(messages).toHaveLength(0); + // 不应调用 SW 的 clearMessages + expect(gmSendMessage).not.toHaveBeenCalledWith( + "CAT_agentConversation", + expect.arrayContaining([expect.objectContaining({ action: "clearMessages" })]) + ); + }); + + it("chatStream ephemeral 传递正确参数", async () => { + const { instance, gmConnect } = createEphemeralInstance({ system: "系统提示" }); + + const stream = await instance.chatStream("流式测试"); + const chunks: StreamChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(gmConnect).toHaveBeenCalledTimes(1); + const connectParams = gmConnect.mock.calls[0][1][0]; + expect(connectParams.ephemeral).toBe(true); + expect(connectParams.system).toBe("系统提示"); + expect(connectParams.messages).toEqual(expect.arrayContaining([{ role: "user", content: "流式测试" }])); + + // 流应正常完成 + expect(chunks.some((c) => c.type === "done")).toBe(true); + }); + + it("chatStream ephemeral 收集 assistant 消息到内存历史", async () => { + const { instance } = createEphemeralInstance(); + + const stream = await instance.chatStream("你好"); + // 消费完 stream + for await (const _chunk of stream) { + // drain + } + + const messages = await instance.getMessages(); + expect(messages.length).toBeGreaterThanOrEqual(2); + expect(messages[0].role).toBe("user"); + const lastMsg = messages[messages.length - 1]; + expect(lastMsg.role).toBe("assistant"); + expect(lastMsg.content).toBe("LLM reply"); + }); + + it("ephemeral 模式下 /new 命令清空内存历史", async () => { + const { instance, gmSendMessage } = createEphemeralInstance(); + + await instance.chat("消息1"); + expect((await instance.getMessages()).length).toBeGreaterThan(0); + + const result = await instance.chat("/new"); + expect(result.command).toBe(true); + + const messages = await instance.getMessages(); + expect(messages).toHaveLength(0); + // ephemeral 的 clear 不调用 SW + expect(gmSendMessage).not.toHaveBeenCalled(); + }); + + it("ephemeral 模式带自定义工具时传递 tools", async () => { + const handler = vi.fn().mockResolvedValue({ result: "ok" }); + const { instance, gmConnect } = createEphemeralInstance({ + tools: [ + { + name: "my_tool", + description: "自定义工具", + parameters: { type: "object", properties: {} }, + handler, + }, + ], + }); + + await instance.chat("使用工具"); + + const connectParams = gmConnect.mock.calls[0][1][0]; + expect(connectParams.tools).toBeDefined(); + expect(connectParams.tools).toHaveLength(1); + expect(connectParams.tools[0].name).toBe("my_tool"); + }); +}); + +// ---- errorCode 透传测试 ---- + +// 创建发送指定事件序列的 mock 连接 +function mockConnectWithEvents(events: any[]): MessageConnect { + return { + onMessage(cb: (msg: any) => void) { + let i = 0; + const send = () => { + if (i < events.length) { + cb({ action: "event", data: events[i++] }); + setTimeout(send, 0); + } + }; + setTimeout(send, 0); + }, + onDisconnect() {}, + sendMessage() {}, + disconnect() {}, + }; +} + +describe("errorCode 透传:chat()", () => { + it("error event 带 errorCode 时,reject 的 Error 应有对应 errorCode", async () => { + const errorEvent = { type: "error", message: "Rate limit exceeded", errorCode: "rate_limit" }; + const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); + + const instance = new ConversationInstance( + mockConversation(), + vi.fn().mockResolvedValue(undefined), + gmConnect, + "uuid", + 20 + ); + + const err = await instance.chat("你好").catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("Rate limit exceeded"); + expect((err as any).errorCode).toBe("rate_limit"); + }); + + it("error event 无 errorCode 时,errorCode 应为 undefined", async () => { + const errorEvent = { type: "error", message: "Unknown error" }; + const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); + + const instance = new ConversationInstance( + mockConversation(), + vi.fn().mockResolvedValue(undefined), + gmConnect, + "uuid", + 20 + ); + + const err = await instance.chat("你好").catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect((err as any).errorCode).toBeUndefined(); + }); + + it("各种 errorCode 值均能正确透传", async () => { + const codes = ["rate_limit", "auth", "tool_timeout", "max_iterations", "api_error"]; + + for (const code of codes) { + const gmConnect = vi + .fn() + .mockResolvedValue(mockConnectWithEvents([{ type: "error", message: "error", errorCode: code }])); + + const instance = new ConversationInstance( + mockConversation(), + vi.fn().mockResolvedValue(undefined), + gmConnect, + "uuid", + 20 + ); + + const err = await instance.chat("test").catch((e) => e); + expect((err as any).errorCode).toBe(code); + } + }); +}); + +describe("errorCode 透传:chatStream()", () => { + // processStream 在收到 error 事件后:将 error chunk 推入队列并设置 done=true。 + // 迭代器先 yield error chunk(done: false),然后返回 { done: true }(正常结束)。 + // 因此不会 throw,只需检查 chunk 中的 errorCode 即可。 + + it("error event 带 errorCode 时,error chunk 应有对应 errorCode", async () => { + const errorEvent = { type: "error", message: "Tool timed out", errorCode: "tool_timeout" }; + const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); + + const instance = new ConversationInstance( + mockConversation(), + vi.fn().mockResolvedValue(undefined), + gmConnect, + "uuid", + 20 + ); + + const stream = await instance.chatStream("你好"); + const chunks: StreamChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const errorChunk = chunks.find((c) => c.type === "error"); + expect(errorChunk).toBeDefined(); + expect(errorChunk!.error).toBe("Tool timed out"); + expect((errorChunk as any).errorCode).toBe("tool_timeout"); + }); + + it("error event 无 errorCode 时,chunk.errorCode 应为 undefined", async () => { + const errorEvent = { type: "error", message: "Some error" }; + const gmConnect = vi.fn().mockResolvedValue(mockConnectWithEvents([errorEvent])); + + const instance = new ConversationInstance( + mockConversation(), + vi.fn().mockResolvedValue(undefined), + gmConnect, + "uuid", + 20 + ); + + const stream = await instance.chatStream("你好"); + const chunks: StreamChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const errorChunk = chunks.find((c) => c.type === "error"); + expect(errorChunk).toBeDefined(); + expect((errorChunk as any).errorCode).toBeUndefined(); + }); + + it("各种 errorCode 均能正确在 chunk 中透传", async () => { + const codes = ["rate_limit", "auth", "tool_timeout", "max_iterations", "api_error"]; + + for (const code of codes) { + const gmConnect = vi + .fn() + .mockResolvedValue(mockConnectWithEvents([{ type: "error", message: "err", errorCode: code }])); + + const instance = new ConversationInstance( + mockConversation(), + vi.fn().mockResolvedValue(undefined), + gmConnect, + "uuid", + 20 + ); + + const stream = await instance.chatStream("test"); + const chunks: StreamChunk[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + const errorChunk = chunks.find((c) => c.type === "error"); + expect((errorChunk as any).errorCode).toBe(code); + } + }); +}); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts new file mode 100644 index 000000000..e0a484a51 --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -0,0 +1,624 @@ +import GMContext from "./gm_context"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { MessageConnect } from "@Packages/message/types"; +import type { + ChatReply, + ChatStreamEvent, + CommandHandler, + ContentBlock, + Conversation, + ConversationApiRequest, + ConversationCreateOptions, + ChatOptions, + StreamChunk, + ToolCall, + ToolDefinition, + ChatMessage, + MessageRole, + MessageContent, +} from "@App/app/service/agent/core/types"; +import { getTextContent } from "@App/app/service/agent/core/content_utils"; + +// 对话实例,暴露给用户脚本 +// 导出供测试使用 +export class ConversationInstance { + private toolHandlers: Map) => Promise> = new Map(); + private toolDefs: ToolDefinition[] = []; + private commandHandlers: Map = new Map(); + private ephemeral: boolean; + private cache?: boolean; + private systemPrompt?: string; + private messageHistory: Array<{ + role: MessageRole; + content: MessageContent; + toolCallId?: string; + toolCalls?: ToolCall[]; + }> = []; + + private background: boolean; + + constructor( + private conv: Conversation, + private gmSendMessage: (api: string, params: any[]) => Promise, + private gmConnect: (api: string, params: any[]) => Promise, + private scriptUuid: string, + private maxIterations: number, + initialTools?: ConversationCreateOptions["tools"], + commands?: Record, + ephemeral?: boolean, + system?: string, + cache?: boolean, + background?: boolean + ) { + this.ephemeral = ephemeral || false; + this.background = background || false; + this.cache = cache; + this.systemPrompt = system; + if (initialTools) { + for (const tool of initialTools) { + this.toolHandlers.set(tool.name, tool.handler); + this.toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); + } + } + + // 注册内置 /new 命令 + this.commandHandlers.set("/new", async () => { + await this.clear(); + return "对话已清空"; + }); + + // 用户传入的 commands 覆盖内置命令 + if (commands) { + for (const [name, handler] of Object.entries(commands)) { + this.commandHandlers.set(name, handler); + } + } + } + + get id() { + return this.conv.id; + } + + get title() { + return this.conv.title; + } + + get modelId() { + return this.conv.modelId; + } + + // 发送消息并获取回复(内置 tool calling 循环) + async chat(content: MessageContent, options?: ChatOptions): Promise { + // 命令拦截(仅纯文本消息支持命令) + const textContent = getTextContent(content); + const cmdResult = await this.tryExecuteCommand(textContent); + if (cmdResult !== undefined) return cmdResult; + + const { toolDefs, handlers } = this.mergeTools(options?.tools); + + // ephemeral 模式:追加 user message 到内存历史 + if (this.ephemeral) { + this.messageHistory.push({ role: "user", content }); + } + + // 通过 GM API connect 建立流式连接 + const connectParams: Record = { + conversationId: this.conv.id, + message: content, + tools: toolDefs.length > 0 ? toolDefs : undefined, + maxIterations: this.maxIterations, + scriptUuid: this.scriptUuid, + }; + + if (this.cache !== undefined) { + connectParams.cache = this.cache; + } + if (this.background) { + connectParams.background = true; + } + if (this.ephemeral) { + connectParams.ephemeral = true; + connectParams.messages = this.messageHistory; + connectParams.system = this.systemPrompt; + connectParams.modelId = this.conv.modelId; + } + + const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + + const reply = await this.processChat(conn, handlers); + + // ephemeral 模式:收集 assistant 响应到内存历史 + if (this.ephemeral) { + if (reply.toolCalls && reply.toolCalls.length > 0) { + this.messageHistory.push({ role: "assistant", content: reply.content, toolCalls: reply.toolCalls }); + for (const tc of reply.toolCalls) { + if (tc.result !== undefined) { + this.messageHistory.push({ role: "tool", content: tc.result, toolCallId: tc.id }); + } + } + } + this.messageHistory.push({ role: "assistant", content: reply.content }); + } + + return reply; + } + + // 流式发送消息 + async chatStream(content: MessageContent, options?: ChatOptions): Promise> { + // 命令拦截:返回单个 done chunk(仅纯文本消息支持命令) + const textContent = getTextContent(content); + const cmdResult = await this.tryExecuteCommand(textContent); + if (cmdResult !== undefined) { + return { + [Symbol.asyncIterator]() { + let yielded = false; + return { + async next(): Promise> { + if (!yielded) { + yielded = true; + return { + value: { type: "done" as const, content: getTextContent(cmdResult.content), command: true }, + done: false, + }; + } + return { value: undefined as any, done: true }; + }, + }; + }, + }; + } + + const { toolDefs, handlers } = this.mergeTools(options?.tools); + + // ephemeral 模式:追加 user message 到内存历史 + if (this.ephemeral) { + this.messageHistory.push({ role: "user", content }); + } + + const connectParams: Record = { + conversationId: this.conv.id, + message: content, + tools: toolDefs.length > 0 ? toolDefs : undefined, + maxIterations: this.maxIterations, + scriptUuid: this.scriptUuid, + }; + + if (this.cache !== undefined) { + connectParams.cache = this.cache; + } + if (this.background) { + connectParams.background = true; + } + if (this.ephemeral) { + connectParams.ephemeral = true; + connectParams.messages = this.messageHistory; + connectParams.system = this.systemPrompt; + connectParams.modelId = this.conv.modelId; + } + + const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + + // ephemeral 模式:包装 stream 以收集 assistant 消息到内存历史 + if (this.ephemeral) { + return this.processStreamEphemeral(conn, handlers); + } + + return this.processStream(conn, handlers); + } + + // 解析命令:"/command args" -> { name, args } + private parseCommand(content: string): { name: string; args: string } | null { + const trimmed = content.trim(); + if (!trimmed.startsWith("/")) return null; + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx === -1) return { name: trimmed, args: "" }; + return { name: trimmed.slice(0, spaceIdx), args: trimmed.slice(spaceIdx + 1).trim() }; + } + + // 尝试执行命令,未注册的命令返回 undefined(正常发送给 LLM) + private async tryExecuteCommand(content: string): Promise { + const parsed = this.parseCommand(content); + if (!parsed) return undefined; + + const handler = this.commandHandlers.get(parsed.name); + if (!handler) return undefined; + + const result = await handler(parsed.args, this); + // 命令结果始终为纯文本 string + return { content: (result || "") as string, command: true }; + } + + // 合并实例级别和调用级别的工具定义 + private mergeTools(callTools?: ChatOptions["tools"]) { + const toolDefs: ToolDefinition[] = [...this.toolDefs]; + const handlers = new Map(this.toolHandlers); + + if (callTools) { + for (const tool of callTools) { + // 调用级别的工具覆盖实例级别的同名工具 + if (!handlers.has(tool.name)) { + toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); + } + handlers.set(tool.name, tool.handler); + } + } + + return { toolDefs, handlers }; + } + + // 获取对话历史 + async getMessages(): Promise { + if (this.ephemeral) { + // ephemeral 模式:从内存历史转换为 ChatMessage 格式 + return this.messageHistory.map((msg, idx) => ({ + id: `ephemeral-${idx}`, + conversationId: this.conv.id, + role: msg.role, + content: msg.content, + toolCallId: msg.toolCallId, + toolCalls: msg.toolCalls, + createtime: Date.now(), + })); + } + const messages = await this.gmSendMessage("CAT_agentConversation", [ + { + action: "getMessages", + conversationId: this.conv.id, + scriptUuid: this.scriptUuid, + } as ConversationApiRequest, + ]); + return messages || []; + } + + // 清空对话消息历史 + async clear(): Promise { + if (this.ephemeral) { + this.messageHistory = []; + return; + } + await this.gmSendMessage("CAT_agentConversation", [ + { + action: "clearMessages", + conversationId: this.conv.id, + scriptUuid: this.scriptUuid, + } as ConversationApiRequest, + ]); + } + + // 持久化对话 + async save(): Promise { + await this.gmSendMessage("CAT_agentConversation", [ + { + action: "save", + conversationId: this.conv.id, + scriptUuid: this.scriptUuid, + } as ConversationApiRequest, + ]); + } + + // 附加到后台运行中的会话,返回流式事件 + async attach(): Promise> { + const conn = await this.gmConnect("CAT_agentAttachToConversation", [ + { conversationId: this.conv.id, scriptUuid: this.scriptUuid }, + ]); + return this.processStream(conn, new Map()); + } + + // 处理非流式 chat 的响应 + private processChat( + conn: MessageConnect, + handlers: Map) => Promise> + ): Promise { + return new Promise((resolve, reject) => { + let content = ""; + let thinking = ""; + const toolCalls: ToolCall[] = []; + const contentBlocks: ContentBlock[] = []; + let currentToolCall: ToolCall | null = null; + let usage: { inputTokens: number; outputTokens: number } | undefined; + + conn.onMessage(async (msg: any) => { + if (msg.action === "executeTools") { + // Service Worker 请求执行 tools + const requestedToolCalls: ToolCall[] = msg.data; + const results = await this.executeTools(requestedToolCalls, handlers); + conn.sendMessage({ action: "toolResults", data: results }); + return; + } + + if (msg.action !== "event") return; + const event: ChatStreamEvent = msg.data; + + switch (event.type) { + case "content_delta": + content += event.delta; + break; + case "thinking_delta": + thinking += event.delta; + break; + case "content_block_complete": + // 收集模型生成的图片/文件/音频 blocks(data 已由 finalize 保存到 attachment 存储) + contentBlocks.push(event.block); + break; + case "tool_call_start": + if (currentToolCall) toolCalls.push(currentToolCall); + currentToolCall = { ...event.toolCall, arguments: event.toolCall.arguments || "" }; + break; + case "tool_call_delta": + if (currentToolCall) currentToolCall.arguments += event.delta; + break; + case "done": { + if (currentToolCall) { + toolCalls.push(currentToolCall); + currentToolCall = null; + } + if (event.usage) usage = event.usage; + // 合并文本和 content blocks 到 MessageContent + let finalContent: MessageContent = content; + if (contentBlocks.length > 0) { + const blocks: ContentBlock[] = []; + if (content) blocks.push({ type: "text", text: content }); + blocks.push(...contentBlocks); + finalContent = blocks; + } + resolve({ + content: finalContent, + thinking: thinking || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + usage, + }); + break; + } + case "error": + reject(Object.assign(new Error(event.message), { errorCode: event.errorCode })); + break; + } + }); + + conn.onDisconnect(() => { + reject(new Error("Connection disconnected")); + }); + }); + } + + // 处理流式 chat 的响应 + private processStream( + conn: MessageConnect, + handlers: Map) => Promise> + ): AsyncIterable { + const chunks: StreamChunk[] = []; + let resolve: (() => void) | null = null; + let done = false; + let error: Error | null = null; + + conn.onMessage(async (msg: any) => { + if (msg.action === "executeTools") { + const requestedToolCalls: ToolCall[] = msg.data; + const results = await this.executeTools(requestedToolCalls, handlers); + conn.sendMessage({ action: "toolResults", data: results }); + return; + } + + if (msg.action !== "event") return; + const event: ChatStreamEvent = msg.data; + + let chunk: StreamChunk | null = null; + switch (event.type) { + case "content_delta": + chunk = { type: "content_delta", content: event.delta }; + break; + case "thinking_delta": + chunk = { type: "thinking_delta", content: event.delta }; + break; + case "content_block_complete": + chunk = { type: "content_block", block: event.block }; + break; + case "tool_call_start": + chunk = { type: "tool_call", toolCall: { ...event.toolCall, arguments: "" } }; + break; + case "done": + chunk = { type: "done", usage: event.usage }; + done = true; + break; + case "error": + chunk = { type: "error", error: event.message, errorCode: event.errorCode }; + error = Object.assign(new Error(event.message), { errorCode: event.errorCode }); + done = true; + break; + } + + if (chunk) { + chunks.push(chunk); + resolve?.(); + } + }); + + conn.onDisconnect(() => { + done = true; + error = error || new Error("Connection disconnected"); + resolve?.(); + }); + + return { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + while (chunks.length === 0 && !done) { + await new Promise((r) => { + resolve = r; + }); + } + + if (chunks.length > 0) { + return { value: chunks.shift()!, done: false }; + } + + if (error && !done) throw error; + return { value: undefined as any, done: true }; + }, + }; + }, + }; + } + + // 处理 ephemeral 流式 chat 的响应(收集 assistant 消息到内存历史) + private processStreamEphemeral( + conn: MessageConnect, + handlers: Map) => Promise> + ): AsyncIterable { + const inner = this.processStream(conn, handlers); + const messageHistory = this.messageHistory; + let content = ""; + const toolCalls: ToolCall[] = []; + + return { + [Symbol.asyncIterator]() { + const iter = inner[Symbol.asyncIterator](); + return { + async next(): Promise> { + const result = await iter.next(); + if (result.done) { + // 流结束时,追加 assistant 消息到历史 + if (content || toolCalls.length > 0) { + messageHistory.push({ + role: "assistant", + content, + toolCalls: toolCalls.length > 0 ? [...toolCalls] : undefined, + }); + } + return result; + } + + const chunk = result.value; + switch (chunk.type) { + case "content_delta": + content += chunk.content || ""; + break; + case "tool_call": + if (chunk.toolCall) { + toolCalls.push(chunk.toolCall); + } + break; + } + return result; + }, + }; + }, + }; + } + + // 执行用户定义的 tool handlers + private async executeTools( + toolCalls: ToolCall[], + handlers: Map) => Promise> + ): Promise> { + const results: Array<{ id: string; result: string }> = []; + + for (const tc of toolCalls) { + const handler = handlers.get(tc.name); + if (!handler) { + results.push({ id: tc.id, result: JSON.stringify({ error: `Tool "${tc.name}" not found` }) }); + continue; + } + + try { + let args: Record = {}; + if (tc.arguments) { + args = JSON.parse(tc.arguments); + } + const result = await handler(args); + results.push({ id: tc.id, result: typeof result === "string" ? result : JSON.stringify(result) }); + } catch (e: any) { + const errorMsg = + e instanceof Error + ? e.message || e.toString() + : typeof e === "string" + ? e + : String(e) || "Tool execution failed"; + results.push({ id: tc.id, result: JSON.stringify({ error: errorMsg }) }); + } + } + + return results; + } +} + +// 运行时 this 是 GM_Base 实例,定义其实际拥有的字段类型 +interface GMBaseContext { + sendMessage: (api: string, params: unknown[]) => Promise; + connect: (api: string, params: unknown[]) => Promise; + scriptRes?: { uuid: string }; +} + +// 构建 ConversationInstance,独立函数避免 this 绑定问题 +// (装饰器方法运行时 this 是 GM_Base 实例,不是 CATAgentApi) +function buildInstance( + ctx: GMBaseContext, + conv: Conversation, + options?: ConversationCreateOptions +): ConversationInstance { + return new ConversationInstance( + conv, + ctx.sendMessage.bind(ctx), + ctx.connect.bind(ctx), + ctx.scriptRes?.uuid || "", + options?.maxIterations || 20, + options?.tools, + options?.commands, + options?.ephemeral, + options?.system, + options?.cache, + options?.background + ); +} + +// CAT.agent.conversation API 对象,注入到脚本上下文 +// 使用 @GMContext.API 装饰器注册到 "CAT.agent.conversation" grant +export default class CATAgentApi { + // 标记为 protected 的内部状态(由 GM_Base 绑定) + @GMContext.protected() + protected sendMessage!: (api: string, params: any[]) => Promise; + + @GMContext.protected() + protected connect!: (api: string, params: any[]) => Promise; + + @GMContext.protected() + protected scriptRes?: any; + + // CAT.agent.conversation.create() + @GMContext.API({ follow: "CAT.agent.conversation" }) + public "CAT.agent.conversation.create"(options: ConversationCreateOptions = {}): Promise { + return (async () => { + if (options.ephemeral) { + // ephemeral 模式:不发请求到 SW,直接在脚本端构造 + const conv: Conversation = { + id: options.id || uuidv4(), + title: "New Chat", + modelId: options.model || "", + system: options.system, + createtime: Date.now(), + updatetime: Date.now(), + }; + return buildInstance(this as unknown as GMBaseContext, conv, options); + } + + const { tools: _tools, ephemeral: _ephemeral, ...serverOptions } = options; + const conv = (await this.sendMessage("CAT_agentConversation", [ + { action: "create", options: serverOptions, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + ])) as Conversation; + return buildInstance(this as unknown as GMBaseContext, conv, options); + })(); + } + + // CAT.agent.conversation.get() + @GMContext.API({ follow: "CAT.agent.conversation" }) + public "CAT.agent.conversation.get"(id: string): Promise { + return (async () => { + const conv = (await this.sendMessage("CAT_agentConversation", [ + { action: "get", id, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + ])) as Conversation | null; + if (!conv) return null; + return buildInstance(this as unknown as GMBaseContext, conv); + })(); + } +} diff --git a/src/app/service/content/gm_api/cat_agent_dom.ts b/src/app/service/content/gm_api/cat_agent_dom.ts new file mode 100644 index 000000000..1208d39ed --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_dom.ts @@ -0,0 +1,134 @@ +// CAT.agent.dom API,注入到脚本上下文 +// 使用 @GMContext.API 装饰器注册到 "CAT.agent.dom" grant + +import GMContext from "./gm_context"; +import type { + DomApiRequest, + ReadPageOptions, + ScreenshotOptions, + ScreenshotResult, + DomActionOptions, + NavigateOptions, + ScrollDirection, + ScrollOptions, + WaitForOptions, + ExecuteScriptOptions, + TabInfo, + NavigateResult, + PageContent, + ActionResult, + ScrollResult, + WaitForResult, + MonitorResult, + MonitorStatus, +} from "@App/app/service/agent/core/types"; + +// 运行时 this 是 GM_Base 实例 +interface GMBaseContext { + sendMessage: (api: string, params: unknown[]) => Promise; + scriptRes?: { uuid: string }; +} + +export default class CATAgentDomApi { + @GMContext.protected() + protected sendMessage!: (api: string, params: any[]) => Promise; + + @GMContext.protected() + protected scriptRes?: any; + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + public "CAT.agent.dom.executeScript"(code: string, options?: ExecuteScriptOptions): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentDom", [ + { action: "executeScript", code, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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, + ]); + } + + @GMContext.API({ follow: "CAT.agent.dom" }) + 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_model.test.ts b/src/app/service/content/gm_api/cat_agent_model.test.ts new file mode 100644 index 000000000..614342ae4 --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_model.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentModelSafeConfig, ModelApiRequest } from "@App/app/service/agent/core/types"; + +// 直接导入以触发装饰器注册 +import CATAgentModelApi from "./cat_agent_model"; +import { GMContextApiGet } from "./gm_context"; + +describe.concurrent("CATAgentModelApi", () => { + it.concurrent("装饰器注册了 list/get/getDefault 三个方法到 CAT.agent.model grant", () => { + // 触发装饰器 + void CATAgentModelApi; + const apis = GMContextApiGet("CAT.agent.model"); + expect(apis).toBeDefined(); + const fnKeys = apis!.map((a) => a.fnKey); + expect(fnKeys).toContain("CAT.agent.model.list"); + expect(fnKeys).toContain("CAT.agent.model.get"); + expect(fnKeys).toContain("CAT.agent.model.getDefault"); + }); + + it.concurrent("list 方法调用 sendMessage 并传递正确的请求", async () => { + const mockSendMessage = vi + .fn() + .mockResolvedValue([ + { id: "m1", name: "GPT-4o", provider: "openai", apiBaseUrl: "https://api.openai.com", model: "gpt-4o" }, + ] as AgentModelSafeConfig[]); + + const ctx = { + sendMessage: mockSendMessage, + scriptRes: { uuid: "test-uuid" }, + }; + + const apis = GMContextApiGet("CAT.agent.model")!; + const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; + const result = await listApi.api.call(ctx); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ + { action: "list", scriptUuid: "test-uuid" } as ModelApiRequest, + ]); + expect(result).toHaveLength(1); + expect((result as AgentModelSafeConfig[])[0].name).toBe("GPT-4o"); + }); + + it.concurrent("get 方法传递 id 参数", async () => { + const mockModel: AgentModelSafeConfig = { + id: "m1", + name: "Claude", + provider: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + model: "claude-sonnet-4-20250514", + }; + const mockSendMessage = vi.fn().mockResolvedValue(mockModel); + + const ctx = { + sendMessage: mockSendMessage, + scriptRes: { uuid: "test-uuid" }, + }; + + const apis = GMContextApiGet("CAT.agent.model")!; + const getApi = apis.find((a) => a.fnKey === "CAT.agent.model.get")!; + const result = await getApi.api.call(ctx, "m1"); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ + { action: "get", id: "m1", scriptUuid: "test-uuid" } as ModelApiRequest, + ]); + expect((result as AgentModelSafeConfig).provider).toBe("anthropic"); + }); + + it.concurrent("getDefault 方法返回默认模型 ID", async () => { + const mockSendMessage = vi.fn().mockResolvedValue("m1"); + + const ctx = { + sendMessage: mockSendMessage, + scriptRes: { uuid: "test-uuid" }, + }; + + const apis = GMContextApiGet("CAT.agent.model")!; + const getDefaultApi = apis.find((a) => a.fnKey === "CAT.agent.model.getDefault")!; + const result = await getDefaultApi.api.call(ctx); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ + { action: "getDefault", scriptUuid: "test-uuid" } as ModelApiRequest, + ]); + expect(result).toBe("m1"); + }); + + it.concurrent("scriptRes 为空时使用空字符串作为 scriptUuid", async () => { + const mockSendMessage = vi.fn().mockResolvedValue([]); + + const ctx = { + sendMessage: mockSendMessage, + scriptRes: undefined, + }; + + const apis = GMContextApiGet("CAT.agent.model")!; + const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; + await listApi.api.call(ctx); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ + { action: "list", scriptUuid: "" } as ModelApiRequest, + ]); + }); +}); diff --git a/src/app/service/content/gm_api/cat_agent_model.ts b/src/app/service/content/gm_api/cat_agent_model.ts new file mode 100644 index 000000000..3cc53c8e8 --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_model.ts @@ -0,0 +1,56 @@ +import type { AgentModelSafeConfig, ModelApiRequest } from "@App/app/service/agent/core/types"; +import GMContext from "./gm_context"; + +// 运行时 this 是 GM_Base 实例 +interface GMBaseContext { + sendMessage: ( + api: string, + params: ModelApiRequest[] + ) => Promise; + scriptRes?: { uuid: string }; +} + +// CAT.agent.model API,注入到脚本上下文 +// 使用 @GMContext.API 装饰器注册到 "CAT.agent.model" grant +export default class CATAgentModelApi { + @GMContext.protected() + protected sendMessage!: ( + api: string, + params: ModelApiRequest[] + ) => Promise; + + @GMContext.protected() + protected scriptRes?: { uuid: string }; + + @GMContext.API({ follow: "CAT.agent.model" }) + public "CAT.agent.model.list"(): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentModel", [ + { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, + ]) as Promise; + } + + @GMContext.API({ follow: "CAT.agent.model" }) + public "CAT.agent.model.get"(id: string): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentModel", [ + { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, + ]) as Promise; + } + + @GMContext.API({ follow: "CAT.agent.model" }) + public "CAT.agent.model.getDefault"(): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentModel", [ + { action: "getDefault", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, + ]) as Promise; + } + + @GMContext.API({ follow: "CAT.agent.model" }) + public "CAT.agent.model.getSummary"(): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentModel", [ + { action: "getSummary", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, + ]) as Promise; + } +} diff --git a/src/app/service/content/gm_api/cat_agent_opfs.test.ts b/src/app/service/content/gm_api/cat_agent_opfs.test.ts new file mode 100644 index 000000000..d189ccf6e --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_opfs.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OPFSApiRequest } from "@App/app/service/agent/core/types"; + +// 直接导入以触发装饰器注册 +import CATAgentOPFSApi from "./cat_agent_opfs"; +import { GMContextApiGet } from "./gm_context"; + +describe.concurrent("CATAgentOPFSApi", () => { + it.concurrent("装饰器注册了 write/read/list/delete/readAttachment 五个方法到 CAT.agent.opfs grant", () => { + void CATAgentOPFSApi; + const apis = GMContextApiGet("CAT.agent.opfs"); + expect(apis).toBeDefined(); + const fnKeys = apis!.map((a) => a.fnKey); + expect(fnKeys).toContain("CAT.agent.opfs.write"); + expect(fnKeys).toContain("CAT.agent.opfs.read"); + expect(fnKeys).toContain("CAT.agent.opfs.list"); + expect(fnKeys).toContain("CAT.agent.opfs.delete"); + expect(fnKeys).toContain("CAT.agent.opfs.readAttachment"); + }); + + it.concurrent("write 方法调用 sendMessage 并传递正确的请求", async () => { + const mockSendMessage = vi.fn().mockResolvedValue({ path: "hello.txt", size: 5 }); + const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } }; + + const apis = GMContextApiGet("CAT.agent.opfs")!; + const writeApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.write")!; + const result = await writeApi.api.call(ctx, "hello.txt", "Hello"); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ + { action: "write", path: "hello.txt", content: "Hello", scriptUuid: "test-uuid" } as OPFSApiRequest, + ]); + expect(result).toEqual({ path: "hello.txt", size: 5 }); + }); + + it.concurrent("read 方法传递 path 参数", async () => { + const mockSendMessage = vi.fn().mockResolvedValue({ path: "f.txt", content: "data", size: 4 }); + const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } }; + + const apis = GMContextApiGet("CAT.agent.opfs")!; + const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; + const result = await readApi.api.call(ctx, "f.txt"); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ + { action: "read", path: "f.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, + ]); + expect((result as any).content).toBe("data"); + }); + + it.concurrent("list 方法可选 path 参数", async () => { + const mockSendMessage = vi.fn().mockResolvedValue([{ name: "a.txt", type: "file", size: 1 }]); + const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } }; + + const apis = GMContextApiGet("CAT.agent.opfs")!; + const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; + + // 不带 path + await listApi.api.call(ctx); + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ + { action: "list", path: undefined, scriptUuid: "test-uuid" } as OPFSApiRequest, + ]); + + // 带 path + mockSendMessage.mockClear(); + await listApi.api.call(ctx, "sub"); + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ + { action: "list", path: "sub", scriptUuid: "test-uuid" } as OPFSApiRequest, + ]); + }); + + it.concurrent("delete 方法传递 path 参数", async () => { + const mockSendMessage = vi.fn().mockResolvedValue({ success: true }); + const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } }; + + const apis = GMContextApiGet("CAT.agent.opfs")!; + const deleteApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.delete")!; + const result = await deleteApi.api.call(ctx, "old.txt"); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ + { action: "delete", path: "old.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, + ]); + expect(result).toEqual({ success: true }); + }); + + it.concurrent("scriptRes 为空时使用空字符串作为 scriptUuid", async () => { + const mockSendMessage = vi.fn().mockResolvedValue([]); + const ctx = { sendMessage: mockSendMessage, scriptRes: undefined }; + + const apis = GMContextApiGet("CAT.agent.opfs")!; + const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; + await listApi.api.call(ctx); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ + { action: "list", path: undefined, scriptUuid: "" } as OPFSApiRequest, + ]); + }); + + // ---- postMessage 通道:SW 直接返回 Blob ---- + + it.concurrent("readAttachment postMessage 通道直接返回 Blob", async () => { + const testBlob = new Blob(["image data"], { type: "image/png" }); + const mockSendMessage = vi.fn().mockResolvedValue({ + id: "att-1", + data: testBlob, + size: 10, + mimeType: "image/png", + }); + const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } }; + + const apis = GMContextApiGet("CAT.agent.opfs")!; + const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; + const result = await readAttachmentApi.api.call(ctx, "att-1"); + + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect((result as any).data).toBe(testBlob); + }); + + it.concurrent("read blob postMessage 通道直接返回 Blob", async () => { + const testBlob = new Blob(["file data"], { type: "image/png" }); + const mockSendMessage = vi.fn().mockResolvedValue({ + path: "img.png", + data: testBlob, + size: 9, + mimeType: "image/png", + }); + const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } }; + + const apis = GMContextApiGet("CAT.agent.opfs")!; + const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; + const result = await readApi.api.call(ctx, "img.png", "blob"); + + expect(mockSendMessage).toHaveBeenCalledTimes(1); + expect((result as any).data).toBe(testBlob); + }); + + // ---- chrome.runtime 通道:SW 返回 blobUrl,客户端 CAT_fetchBlob 还原 ---- + + it.concurrent("readAttachment chrome.runtime 通道通过 CAT_fetchBlob 还原 Blob", async () => { + const testBlob = new Blob(["image data"], { type: "image/png" }); + const mockSendMessage = vi.fn().mockImplementation((api: string) => { + if (api === "CAT_agentOPFS") { + return Promise.resolve({ + id: "att-1", + blobUrl: "blob:chrome-extension://test/123", + size: 10, + mimeType: "image/png", + }); + } + if (api === "CAT_fetchBlob") { + return Promise.resolve(testBlob); + } + return Promise.resolve(undefined); + }); + const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } }; + + const apis = GMContextApiGet("CAT.agent.opfs")!; + const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; + const result = await readAttachmentApi.api.call(ctx, "att-1"); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/123"]); + expect((result as any).data).toBe(testBlob); + expect((result as any).blobUrl).toBeUndefined(); + }); + + it.concurrent("read blob chrome.runtime 通道通过 CAT_fetchBlob 还原 Blob", async () => { + const testBlob = new Blob(["file data"], { type: "image/png" }); + const mockSendMessage = vi.fn().mockImplementation((api: string) => { + if (api === "CAT_agentOPFS") { + return Promise.resolve({ + path: "img.png", + blobUrl: "blob:chrome-extension://test/456", + size: 9, + mimeType: "image/png", + }); + } + if (api === "CAT_fetchBlob") { + return Promise.resolve(testBlob); + } + return Promise.resolve(undefined); + }); + const ctx = { sendMessage: mockSendMessage, scriptRes: { uuid: "test-uuid" } }; + + const apis = GMContextApiGet("CAT.agent.opfs")!; + const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; + const result = await readApi.api.call(ctx, "img.png", "blob"); + + expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/456"]); + expect((result as any).data).toBe(testBlob); + expect((result as any).blobUrl).toBeUndefined(); + }); +}); diff --git a/src/app/service/content/gm_api/cat_agent_opfs.ts b/src/app/service/content/gm_api/cat_agent_opfs.ts new file mode 100644 index 000000000..6355e5517 --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_opfs.ts @@ -0,0 +1,75 @@ +import type { OPFSApiRequest } from "@App/app/service/agent/core/types"; +import GMContext from "./gm_context"; + +// 运行时 this 是 GM_Base 实例 +interface GMBaseContext { + sendMessage: (api: string, params: any[]) => Promise; + scriptRes?: { uuid: string }; +} + +// CAT.agent.opfs API,注入到脚本上下文 +// 使用 @GMContext.API 装饰器注册到 "CAT.agent.opfs" grant +export default class CATAgentOPFSApi { + @GMContext.protected() + protected sendMessage!: GMBaseContext["sendMessage"]; + + @GMContext.protected() + protected scriptRes?: { uuid: string }; + + @GMContext.API({ follow: "CAT.agent.opfs" }) + public "CAT.agent.opfs.write"(path: string, content: string | Blob): Promise<{ path: string; size: number }> { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentOPFS", [ + { action: "write", path, content, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, + ]) as Promise<{ path: string; size: number }>; + } + + @GMContext.API({ follow: "CAT.agent.opfs" }) + public async "CAT.agent.opfs.read"( + path: string, + format?: "text" | "blob" + ): Promise<{ path: string; content?: string; data?: Blob; size: number; mimeType?: string }> { + const ctx = this as unknown as GMBaseContext; + const result = await ctx.sendMessage("CAT_agentOPFS", [ + { action: "read", path, format, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, + ]); + // blob 格式:postMessage 通道直接返回 Blob;chrome.runtime 通道返回 blobUrl 需转换 + if (format === "blob" && !(result.data instanceof Blob) && result.blobUrl) { + result.data = await ctx.sendMessage("CAT_fetchBlob", [result.blobUrl]); + delete result.blobUrl; + } + return result; + } + + @GMContext.API({ follow: "CAT.agent.opfs" }) + public "CAT.agent.opfs.list"(path?: string): Promise> { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentOPFS", [ + { action: "list", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, + ]) as Promise>; + } + + @GMContext.API({ follow: "CAT.agent.opfs" }) + public async "CAT.agent.opfs.readAttachment"( + id: string + ): Promise<{ id: string; data: Blob; size: number; mimeType?: string }> { + const ctx = this as unknown as GMBaseContext; + const result = await ctx.sendMessage("CAT_agentOPFS", [ + { action: "readAttachment", id, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, + ]); + // postMessage 通道直接返回 Blob;chrome.runtime 通道返回 blobUrl 需转换 + if (!(result.data instanceof Blob) && result.blobUrl) { + result.data = await ctx.sendMessage("CAT_fetchBlob", [result.blobUrl]); + delete result.blobUrl; + } + return result; + } + + @GMContext.API({ follow: "CAT.agent.opfs" }) + public "CAT.agent.opfs.delete"(path: string): Promise<{ success: true }> { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentOPFS", [ + { action: "delete", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, + ]) as Promise<{ success: true }>; + } +} diff --git a/src/app/service/content/gm_api/cat_agent_skills.ts b/src/app/service/content/gm_api/cat_agent_skills.ts new file mode 100644 index 000000000..8e9dd6314 --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_skills.ts @@ -0,0 +1,84 @@ +import type { SkillApiRequest, SkillRecord, SkillSummary } from "@App/app/service/agent/core/types"; +import GMContext from "./gm_context"; + +// 运行时 this 是 GM_Base 实例 +interface GMBaseContext { + sendMessage: ( + api: string, + params: SkillApiRequest[] + ) => Promise; + scriptRes?: { uuid: string }; +} + +// CAT.agent.skills API,注入到脚本上下文 +// 使用 @GMContext.API 装饰器注册到 "CAT.agent.skills" grant +export default class CATAgentSkillsApi { + @GMContext.protected() + protected sendMessage!: ( + api: string, + params: SkillApiRequest[] + ) => Promise; + + @GMContext.protected() + protected scriptRes?: { uuid: string }; + + @GMContext.API({ follow: "CAT.agent.skills" }) + 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 { + 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" }) + public "CAT.agent.skills.install"( + skillMd: string, + scripts?: Array<{ name: string; code: string }>, + references?: Array<{ name: string; content: string }> + ): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentSkills", [ + { + action: "install", + skillMd, + scripts, + references, + scriptUuid: ctx.scriptRes?.uuid || "", + } as SkillApiRequest, + ]) as Promise; + } + + @GMContext.API({ follow: "CAT.agent.skills" }) + 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; + } + + @GMContext.API({ follow: "CAT.agent.skills" }) + public "CAT.agent.skills.call"( + skillName: string, + scriptName: string, + params?: Record + ): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentSkills", [ + { + action: "call", + skillName, + scriptName, + params, + scriptUuid: ctx.scriptRes?.uuid || "", + } as SkillApiRequest, + ]); + } +} diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts new file mode 100644 index 000000000..83f89fbeb --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -0,0 +1,106 @@ +import GMContext from "./gm_context"; +import type { AgentTask, AgentTaskApiRequest, AgentTaskTrigger } from "@App/app/service/agent/core/types"; +import type EventEmitter from "eventemitter3"; + +// 运行时 this 是 GM_Base 实例 +interface GMBaseContext { + sendMessage: (api: string, params: unknown[]) => Promise; + scriptRes?: { uuid: string }; + EE?: EventEmitter | null; +} + +// 内部 listener 计数器 +let listenerCounter = 0; +// listener id → { eventName, callback } 映射,供 removeListener 使用 +const listenerMap = new Map void }>(); + +// CAT.agent.task API,注入到脚本上下文 +export default class CATAgentTaskApi { + @GMContext.protected() + protected sendMessage!: (api: string, params: any[]) => Promise; + + @GMContext.protected() + protected scriptRes?: any; + + @GMContext.protected() + protected EE?: EventEmitter | null; + + @GMContext.API({ follow: "CAT.agent.task" }) + public "CAT.agent.task.create"( + options: Omit + ): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentTask", [ + { + action: "create", + task: { ...options, sourceScriptUuid: ctx.scriptRes?.uuid || "" }, + } as AgentTaskApiRequest, + ]) as Promise; + } + + @GMContext.API({ follow: "CAT.agent.task" }) + public "CAT.agent.task.list"(): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentTask", [{ action: "list" } as AgentTaskApiRequest]) as Promise; + } + + @GMContext.API({ follow: "CAT.agent.task" }) + public "CAT.agent.task.get"(id: string): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentTask", [{ action: "get", id } as AgentTaskApiRequest]) as Promise< + AgentTask | undefined + >; + } + + @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; + } + + @GMContext.API({ follow: "CAT.agent.task" }) + public "CAT.agent.task.remove"(id: string): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentTask", [{ action: "delete", id } as AgentTaskApiRequest]) as Promise; + } + + @GMContext.API({ follow: "CAT.agent.task" }) + public "CAT.agent.task.runNow"(id: string): Promise { + const ctx = this as unknown as GMBaseContext; + return ctx.sendMessage("CAT_agentTask", [{ action: "runNow", id } as AgentTaskApiRequest]) as Promise; + } + + // 监听任务触发事件 + // 利用 EE.on("agentTask:{taskId}", callback) 注册监听 + @GMContext.API({ follow: "CAT.agent.task" }) + public "CAT.agent.task.addListener"(taskId: string, callback: (trigger: AgentTaskTrigger) => void): number { + const ctx = this as unknown as GMBaseContext; + if (!ctx.EE) return 0; + + const listenerId = ++listenerCounter; + const eventName = `agentTask:${taskId}`; + + const wrappedCallback = (data: AgentTaskTrigger) => { + callback(data); + }; + + ctx.EE.on(eventName, wrappedCallback); + listenerMap.set(listenerId, { eventName, callback: wrappedCallback }); + + return listenerId; + } + + @GMContext.API({ follow: "CAT.agent.task" }) + public "CAT.agent.task.removeListener"(listenerId: number): void { + const ctx = this as unknown as GMBaseContext; + if (!ctx.EE) return; + + const entry = listenerMap.get(listenerId); + if (entry) { + ctx.EE.off(entry.eventName, entry.callback); + listenerMap.delete(listenerId); + } + } +} 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 2a76ed845..8a94e1b66 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -1208,3 +1208,119 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 expect(ret).toEqual(123); }); }); + +describe("@grant CAT.agent.conversation", () => { + it("CAT.agent.conversation 应该在沙盒中可访问", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["CAT.agent.conversation", "GM_log"]; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: undefined as any, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + script.code = `return { + CAT: typeof CAT, + create: typeof CAT.agent.conversation.create, + get: typeof CAT.agent.conversation.get, + }`; + exec.scriptFunc = compileScript(compileScriptCode(script)); + const ret = await exec.exec(); + expect(ret.CAT).toEqual("object"); + expect(ret.create).toEqual("function"); + expect(ret.get).toEqual("function"); + }); +}); + +describe("@grant CAT.agent.dom", () => { + it("CAT.agent.dom 所有方法应该在沙盒中可访问", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["CAT.agent.dom"]; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: undefined as any, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + script.code = `return { + CAT: typeof CAT, + agent: typeof CAT.agent, + dom: typeof CAT.agent.dom, + listTabs: typeof CAT.agent.dom.listTabs, + navigate: typeof CAT.agent.dom.navigate, + readPage: typeof CAT.agent.dom.readPage, + screenshot: typeof CAT.agent.dom.screenshot, + click: typeof CAT.agent.dom.click, + fill: typeof CAT.agent.dom.fill, + scroll: typeof CAT.agent.dom.scroll, + waitFor: typeof CAT.agent.dom.waitFor, + }`; + exec.scriptFunc = compileScript(compileScriptCode(script)); + const ret = await exec.exec(); + expect(ret.CAT).toEqual("object"); + expect(ret.agent).toEqual("object"); + expect(ret.dom).toEqual("object"); + expect(ret.listTabs).toEqual("function"); + expect(ret.navigate).toEqual("function"); + expect(ret.readPage).toEqual("function"); + expect(ret.screenshot).toEqual("function"); + expect(ret.click).toEqual("function"); + expect(ret.fill).toEqual("function"); + expect(ret.scroll).toEqual("function"); + expect(ret.waitFor).toEqual("function"); + }); + + it("CAT.agent.dom.readPage 应通过 sendMessage 发送正确参数", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.uuid = "test-uuid"; + script.metadata.grant = ["CAT.agent.dom"]; + const mockSendMessage = vi.fn().mockResolvedValue({ data: { title: "Test", url: "https://example.com" } }); + const mockMessage = { + sendMessage: mockSendMessage, + } as unknown as Message; + const exec = new ExecScript(script, { + envPrefix: "offscreen", + message: mockMessage, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + script.code = `return CAT.agent.dom.readPage({ tabId: 1, mode: "summary", maxLength: 2000 });`; + exec.scriptFunc = compileScript(compileScriptCode(script)); + const ret = await exec.exec(); + expect(ret).toEqual({ title: "Test", url: "https://example.com" }); + expect(mockSendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "offscreen/runtime/gmApi", + data: expect.objectContaining({ + api: "CAT_agentDom", + params: [ + expect.objectContaining({ + action: "readPage", + options: { tabId: 1, mode: "summary", maxLength: 2000 }, + scriptUuid: "test-uuid", + }), + ], + }), + }) + ); + }); + + it("未 grant CAT.agent.dom 时方法不可用", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_log"]; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: undefined as any, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + script.code = `return { hasCat: typeof CAT !== "undefined" && CAT?.agent?.dom?.readPage !== undefined }`; + exec.scriptFunc = compileScript(compileScriptCode(script)); + const ret = await exec.exec(); + 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 a98e9c1e8..89c116f9a 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -18,13 +18,27 @@ 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"; import { type TGMKeyValue } from "@App/app/repo/value"; import type { ContextType } from "./gm_xhr"; import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; -import { ScriptEnvTag } from "@Packages/message/consts"; +// 导入 CAT Agent API 以触发装饰器注册 +// 注意:不能使用 import "./cat_agent",sideEffects 配置会导致 tree-shaking 移除纯副作用导入 +import CATAgentApi from "./cat_agent"; +void CATAgentApi; +import CATAgentSkillsApi from "./cat_agent_skills"; +void CATAgentSkillsApi; +import CATAgentDomApi from "./cat_agent_dom"; +void CATAgentDomApi; +import CATAgentTaskApi from "./cat_agent_task"; +void CATAgentTaskApi; +import CATAgentModelApi from "./cat_agent_model"; +void CATAgentModelApi; +import CATAgentOPFSApi from "./cat_agent_opfs"; +void CATAgentOPFSApi; // 内部函数呼叫定义 export interface IGM_Base { diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index fa802ce52..b0983303b 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -79,6 +79,14 @@ export default class ScriptingRuntime { case "CAT_fetchBlob": { return fetch(data.params[0]).then((res) => res.blob()); } + case "CAT_agentOPFS": { + // chrome.runtime 不支持 Blob,write 操作的 Blob content 需先转为 blob URL + const req = data.params[0]; + if (req?.action === "write" && req.content instanceof Blob) { + req.content = makeBlobURL({ blob: req.content, persistence: true }) as string; + } + return false; // 继续转发到 SW + } case "CAT_fetchDocument": { const [url, isContent] = data.params; // 根据来源选择不同的消息桥(content / inject) diff --git a/src/app/service/offscreen/client.ts b/src/app/service/offscreen/client.ts index d168f6bcb..4dfafad74 100644 --- a/src/app/service/offscreen/client.ts +++ b/src/app/service/offscreen/client.ts @@ -37,6 +37,61 @@ export function createObjectURL(msgSender: MessageSend, params: { blob: Blob; pe return sendMessage(msgSender, "offscreen/createObjectURL", params); } +// 执行 Skill Script +export function executeSkillScript( + msgSender: MessageSend, + params: { + uuid: string; + code: string; + args: Record; + grants: string[]; + name: string; + requires?: Array<{ url: string; content: string }>; + configValues?: Record; + } +) { + return sendMessage(msgSender, "offscreen/executeSkillScript", params); +} + +// HTML 内容提取 +export async function extractHtmlContent(msgSender: MessageSend, html: string): Promise { + const result = await sendMessage(msgSender, "offscreen/htmlExtractor/extractHtmlContent", html); + return result ?? null; +} + +// HTML 内容提取(带 selector 标注) +export async function extractHtmlWithSelectors(msgSender: MessageSend, html: string): Promise { + const result = await sendMessage(msgSender, "offscreen/htmlExtractor/extractHtmlWithSelectors", html); + return result ?? null; +} + +// Bing 搜索结果提取 +export async function extractBingResults( + msgSender: MessageSend, + html: string +): Promise> { + const result = await sendMessage(msgSender, "offscreen/htmlExtractor/extractBingResults", html); + return result ?? []; +} + +// 百度搜索结果提取 +export async function extractBaiduResults( + msgSender: MessageSend, + html: string +): Promise> { + const result = await sendMessage(msgSender, "offscreen/htmlExtractor/extractBaiduResults", html); + return result ?? []; +} + +// 搜索结果提取 +export async function extractSearchResults( + msgSender: MessageSend, + html: string +): Promise> { + const result = await sendMessage(msgSender, "offscreen/htmlExtractor/extractSearchResults", html); + return result ?? []; +} + export class VscodeConnectClient extends Client { constructor(msgSender: MessageSend) { super(msgSender, "offscreen/vscodeConnect"); diff --git a/src/app/service/offscreen/html_extractor.test.ts b/src/app/service/offscreen/html_extractor.test.ts new file mode 100644 index 000000000..eec41fe57 --- /dev/null +++ b/src/app/service/offscreen/html_extractor.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from "vitest"; +import { HtmlExtractorService } from "./html_extractor"; + +// 创建不需要消息通道的 HtmlExtractorService 实例 +function createService(): HtmlExtractorService { + return new HtmlExtractorService({} as any); +} + +describe("extractHtmlWithSelectors", () => { + it("should extract content with selector annotations on headings", () => { + const service = createService(); + const html = ` +
    +

    Page Title

    +

    Some content here

    +

    Section A

    +

    Section A content

    +
    + `; + + const result = service.extractHtmlWithSelectors(html); + expect(result).not.toBeNull(); + // 标题应该有 selector 注释 + expect(result).toContain("# Page Title "); + expect(result).toContain("Product list"); + }); + + it("should use id-based selector when element has id", () => { + const service = createService(); + const html = ` +

    Pricing

    + `; + + const result = service.extractHtmlWithSelectors(html); + expect(result).toContain("## Pricing "); + }); + + it("should use parent > child selector with classes", () => { + const service = createService(); + const html = ` +
    +

    Info

    +
    + `; + + const result = service.extractHtmlWithSelectors(html); + expect(result).not.toBeNull(); + // 应该是 div.container > h3.info-title 这样的格式 + expect(result).toContain("### Info "); + }); + + it("should use parent id shortcut in selector", () => { + const service = createService(); + const html = ` +
    +

    Hello

    +
    + `; + + const result = service.extractHtmlWithSelectors(html); + expect(result).toContain("## Hello "); + }); + + it("should handle links, lists, code blocks same as walkNode", () => { + const service = createService(); + const html = ` +
      +
    • Item 1
    • +
    • Item 2
    • +
    + Link +
    code block
    + inline code + `; + + const result = service.extractHtmlWithSelectors(html); + expect(result).not.toBeNull(); + expect(result).toContain("- Item 1"); + expect(result).toContain("- Item 2"); + expect(result).toContain("[Link](https://example.com)"); + expect(result).toContain("```\ncode block\n```"); + expect(result).toContain("`inline code`"); + }); + + it("should remove script/style/nav/header/footer/aside/svg/noscript/iframe", () => { + const service = createService(); + const html = ` + +
    Header
    +
    +

    Main content

    +
    +
    Footer
    + + + `; + + const result = service.extractHtmlWithSelectors(html); + expect(result).not.toBeNull(); + expect(result).toContain("Main content"); + expect(result).not.toContain("Navigation"); + expect(result).not.toContain("Header"); + expect(result).not.toContain("Footer"); + expect(result).not.toContain("alert"); + }); + + it("should return null for empty body", () => { + const service = createService(); + const html = ``; + + const result = service.extractHtmlWithSelectors(html); + expect(result).toBeNull(); + }); + + it("should return null for invalid HTML", () => { + const service = createService(); + // DOMParser 通常不会抛错,但如果 catch 被触发应该返回 null + // 这里测试空字符串 + const result = service.extractHtmlWithSelectors(""); + // DOMParser 对空字符串会创建空 body + expect(result).toBeNull(); + }); + + it("should not annotate empty div/section elements", () => { + const service = createService(); + const html = ` +
    +

    content

    + `; + + const result = service.extractHtmlWithSelectors(html); + expect(result).not.toBeNull(); + expect(result).not.toContain("#empty"); + expect(result).toContain("#has-content"); + }); + + it("should handle br and hr elements", () => { + const service = createService(); + const html = ` +

    Before

    +
    +

    After

    + `; + + const result = service.extractHtmlWithSelectors(html); + expect(result).not.toBeNull(); + expect(result).toContain("---"); + expect(result).toContain("Before"); + expect(result).toContain("After"); + }); + + it("should limit class names to 2 in selector", () => { + const service = createService(); + const html = ` +

    Title

    + `; + + const result = service.extractHtmlWithSelectors(html); + expect(result).not.toBeNull(); + // 最多取 2 个 class + expect(result).toContain("h2.cls1.cls2"); + expect(result).not.toContain("cls3"); + }); +}); diff --git a/src/app/service/offscreen/html_extractor.ts b/src/app/service/offscreen/html_extractor.ts new file mode 100644 index 000000000..e0bdd85c3 --- /dev/null +++ b/src/app/service/offscreen/html_extractor.ts @@ -0,0 +1,333 @@ +import type { Group } from "@Packages/message/server"; +// 触发所有搜索引擎注册(副作用导入) +import "./search_engines"; +import { searchEngineRegistry } from "./search_engines/registry"; +import type { SearchResult } from "./search_engines/types"; + +export type { SearchResult } from "./search_engines/types"; + +export class HtmlExtractorService { + constructor(private group: Group) {} + + init() { + this.group.on("extractHtmlContent", (html: string) => this.extractHtmlContent(html)); + this.group.on("extractHtmlWithSelectors", (html: string) => this.extractHtmlWithSelectors(html)); + this.group.on("extractSearchResults", (html: string) => this.extractSearchResults(html)); + this.group.on("extractBingResults", (html: string) => this.extractBingResults(html)); + this.group.on("extractBaiduResults", (html: string) => this.extractBaiduResults(html)); + } + + extractHtmlContent(html: string): string | null { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + // 移除不需要的元素 + const removeSelectors = ["script", "style", "nav", "header", "footer", "aside", "svg", "noscript", "iframe"]; + for (const selector of removeSelectors) { + doc.querySelectorAll(selector).forEach((el) => el.remove()); + } + + // 优先取主内容区域 + const mainEl = doc.querySelector('main, article, [role="main"]') || doc.body; + if (!mainEl) return null; + + const lines: string[] = []; + this.walkNode(mainEl, lines); + const result = lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return result.length > 0 ? result : null; + } catch { + return null; + } + } + + private walkNode(node: Node, lines: string[]) { + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = (child.textContent || "").trim(); + if (text) { + lines.push(text); + } + } else if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element; + const tag = el.tagName.toLowerCase(); + + // 标题 → markdown 格式 + if (/^h[1-6]$/.test(tag)) { + const level = parseInt(tag[1]); + const text = (el.textContent || "").trim(); + if (text) { + lines.push(""); + lines.push("#".repeat(level) + " " + text); + lines.push(""); + } + continue; + } + + // 列表项 + if (tag === "li") { + const text = (el.textContent || "").trim(); + if (text) { + lines.push("- " + text); + } + continue; + } + + // 段落 + if (tag === "p") { + const text = (el.textContent || "").trim(); + if (text) { + lines.push(""); + lines.push(text); + lines.push(""); + } + continue; + } + + // 链接 → 保留 href + if (tag === "a") { + const text = (el.textContent || "").trim(); + const href = el.getAttribute("href"); + if (text && href && !href.startsWith("javascript:")) { + lines.push(`[${text}](${href})`); + } else if (text) { + lines.push(text); + } + continue; + } + + // 代码块 + if (tag === "pre") { + const text = (el.textContent || "").trim(); + if (text) { + lines.push(""); + lines.push("```"); + lines.push(text); + lines.push("```"); + lines.push(""); + } + continue; + } + + // 行内代码 + if (tag === "code" && el.parentElement?.tagName.toLowerCase() !== "pre") { + const text = (el.textContent || "").trim(); + if (text) { + lines.push("`" + text + "`"); + } + continue; + } + + // 换行 + if (tag === "br") { + lines.push(""); + continue; + } + + // 分隔线 + if (tag === "hr") { + lines.push(""); + lines.push("---"); + lines.push(""); + continue; + } + + // 递归处理其他元素 + this.walkNode(el, lines); + + // 块级元素后添加空行 + if (["div", "section", "blockquote", "table", "figure"].includes(tag)) { + lines.push(""); + } + } + } + } + + // 带 selector 标注的 HTML 提取,用于 tab 工具 + // 在标题/区块元素旁标注 CSS selector 路径,方便 Agent 后续精确提取 + extractHtmlWithSelectors(html: string): string | null { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + + const removeSelectors = ["script", "style", "nav", "header", "footer", "aside", "svg", "noscript", "iframe"]; + for (const selector of removeSelectors) { + doc.querySelectorAll(selector).forEach((el) => el.remove()); + } + + const mainEl = doc.querySelector('main, article, [role="main"]') || doc.body; + if (!mainEl) return null; + + const lines: string[] = []; + this.walkNodeWithSelectors(mainEl, lines); + const result = lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return result.length > 0 ? result : null; + } catch { + return null; + } + } + + // 生成元素的简短 CSS selector 路径 + private buildSelector(el: Element): string { + if (el.id) return `#${el.id}`; + const tag = el.tagName.toLowerCase(); + const classes = Array.from(el.classList).slice(0, 2); + const classStr = classes.length > 0 ? "." + classes.join(".") : ""; + const current = tag + classStr; + + const parent = el.parentElement; + if (!parent || parent.tagName === "HTML" || parent.tagName === "BODY") { + return current; + } + // 最多向上追溯 2 层 + const parentTag = parent.tagName.toLowerCase(); + if (parent.id) return `#${parent.id} > ${current}`; + const parentClasses = Array.from(parent.classList).slice(0, 2); + const parentClassStr = parentClasses.length > 0 ? "." + parentClasses.join(".") : ""; + return parentTag + parentClassStr + " > " + current; + } + + // 标注 selector 的元素集合 + private static ANNOTATE_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6", "div", "section", "article", "main"]); + + private walkNodeWithSelectors(node: Node, lines: string[]) { + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = (child.textContent || "").trim(); + if (text) { + lines.push(text); + } + } else if (child.nodeType === Node.ELEMENT_NODE) { + const el = child as Element; + const tag = el.tagName.toLowerCase(); + + // 标题 → markdown + selector 标注 + if (/^h[1-6]$/.test(tag)) { + const level = parseInt(tag[1]); + const text = (el.textContent || "").trim(); + if (text) { + const sel = this.buildSelector(el); + lines.push(""); + lines.push(`${"#".repeat(level)} ${text} `); + lines.push(""); + } + continue; + } + + if (tag === "li") { + const text = (el.textContent || "").trim(); + if (text) { + lines.push("- " + text); + } + continue; + } + + if (tag === "p") { + const text = (el.textContent || "").trim(); + if (text) { + lines.push(""); + lines.push(text); + lines.push(""); + } + continue; + } + + if (tag === "a") { + const text = (el.textContent || "").trim(); + const href = el.getAttribute("href"); + if (text && href && !href.startsWith("javascript:")) { + lines.push(`[${text}](${href})`); + } else if (text) { + lines.push(text); + } + continue; + } + + if (tag === "pre") { + const text = (el.textContent || "").trim(); + if (text) { + lines.push(""); + lines.push("```"); + lines.push(text); + lines.push("```"); + lines.push(""); + } + continue; + } + + if (tag === "code" && el.parentElement?.tagName.toLowerCase() !== "pre") { + const text = (el.textContent || "").trim(); + if (text) { + lines.push("`" + text + "`"); + } + continue; + } + + if (tag === "br") { + lines.push(""); + continue; + } + + if (tag === "hr") { + lines.push(""); + lines.push("---"); + lines.push(""); + continue; + } + + // 区块元素添加 selector 标注 + if (HtmlExtractorService.ANNOTATE_TAGS.has(tag) && !/^h[1-6]$/.test(tag)) { + const sel = this.buildSelector(el); + const hasContent = (el.textContent || "").trim().length > 0; + if (hasContent) { + lines.push(``); + } + } + + this.walkNodeWithSelectors(el, lines); + + if (["div", "section", "blockquote", "table", "figure"].includes(tag)) { + lines.push(""); + } + } + } + } + + // 解析 Bing 搜索结果(适配层:委托给 bingEngine 插件) + extractBingResults(html: string): SearchResult[] { + try { + const doc = new DOMParser().parseFromString(html, "text/html"); + return searchEngineRegistry.get("bing")?.extract(doc) ?? []; + } catch { + return []; + } + } + + // 解析百度搜索结果(适配层:委托给 baiduEngine 插件) + extractBaiduResults(html: string): SearchResult[] { + try { + const doc = new DOMParser().parseFromString(html, "text/html"); + return searchEngineRegistry.get("baidu")?.extract(doc) ?? []; + } catch { + return []; + } + } + + // 解析 DuckDuckGo 搜索结果(适配层:委托给 duckduckgoEngine 插件) + extractSearchResults(html: string): SearchResult[] { + try { + const doc = new DOMParser().parseFromString(html, "text/html"); + return searchEngineRegistry.get("duckduckgo")?.extract(doc) ?? []; + } catch { + return []; + } + } +} diff --git a/src/app/service/offscreen/index.ts b/src/app/service/offscreen/index.ts index 9dde44955..41c6e738a 100644 --- a/src/app/service/offscreen/index.ts +++ b/src/app/service/offscreen/index.ts @@ -7,6 +7,7 @@ import { sendMessage } from "@Packages/message/client"; import GMApi from "./gm_api"; import { MessageQueue } from "@Packages/message/message_queue"; import { VSCodeConnect } from "./vscode-connect"; +import { HtmlExtractorService } from "./html_extractor"; import { makeBlobURL } from "@App/pkg/utils/utils"; // offscreen环境的管理器 @@ -53,17 +54,26 @@ export class OffscreenManager { script.init(); // 转发从sandbox来的gm api请求,通过postMessage通道传输(支持Blob等结构化克隆) forwardMessage("serviceWorker", "runtime/gmApi", this.windowServer, this.msgSender); + // 转发 Skill Script 执行请求到 sandbox + forwardMessage("sandbox", "executeSkillScript", this.windowServer, this.windowMessage); // 转发valueUpdate与emitEvent forwardMessage("sandbox", "runtime/valueUpdate", this.windowServer, this.windowMessage); forwardMessage("sandbox", "runtime/emitEvent", this.windowServer, this.windowMessage); - const gmApi = new GMApi(this.windowServer.group("gmApi")); gmApi.init(); const vscodeConnect = new VSCodeConnect(this.windowServer.group("vscodeConnect"), this.msgSender); vscodeConnect.init(); + const htmlExtractor = new HtmlExtractorService(this.windowServer.group("htmlExtractor")); + htmlExtractor.init(); this.windowServer.on("createObjectURL", async (params: { blob: Blob; persistence: boolean }) => { return makeBlobURL(params) as string; }); + + // fetch blob URL 并返回 Blob(供 SW 在 chrome.runtime 通道下还原 content script 创建的 blob URL) + this.windowServer.on("fetchBlob", async (params: { url: string }) => { + const res = await fetch(params.url); + return await res.blob(); + }); } } diff --git a/src/app/service/offscreen/search_engines/baidu.test.ts b/src/app/service/offscreen/search_engines/baidu.test.ts new file mode 100644 index 000000000..663e15769 --- /dev/null +++ b/src/app/service/offscreen/search_engines/baidu.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { baiduEngine } from "./baidu"; + +/** 用 DOMParser 从 HTML 字符串构建 Document */ +function parseHtml(html: string): Document { + return new DOMParser().parseFromString(html, "text/html"); +} + +describe("baiduEngine", () => { + it("引擎名为 baidu", () => { + expect(baiduEngine.name).toBe("baidu"); + }); + + it("从标准百度 HTML 中提取结果", () => { + const html = ` + +
    +

    百度结果标题

    +
    这是摘要文本
    +
    +
    + + 另一个摘要 +
    + `; + + const results = baiduEngine.extract(parseHtml(html)); + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + title: "百度结果标题", + url: "https://example.com", + snippet: "这是摘要文本", + }); + expect(results[1].title).toBe("另一个标题"); + expect(results[1].url).toBe("https://another.com"); + }); + + it("没有 .result 元素时返回空数组", () => { + const html = `

    无结果

    `; + expect(baiduEngine.extract(parseHtml(html))).toEqual([]); + }); + + it("跳过没有链接的 .result 元素", () => { + const html = ` + +
    +

    没有链接

    +
    + `; + expect(baiduEngine.extract(parseHtml(html))).toEqual([]); + }); + + it("snippet 为空时仍返回结果", () => { + const html = ` + +
    +

    仅标题

    +
    + `; + const results = baiduEngine.extract(parseHtml(html)); + expect(results).toHaveLength(1); + expect(results[0].snippet).toBe(""); + }); +}); diff --git a/src/app/service/offscreen/search_engines/baidu.ts b/src/app/service/offscreen/search_engines/baidu.ts new file mode 100644 index 000000000..e81807d15 --- /dev/null +++ b/src/app/service/offscreen/search_engines/baidu.ts @@ -0,0 +1,30 @@ +import type { SearchEngine, SearchResult } from "./types"; +import { searchEngineRegistry } from "./registry"; + +/** 百度搜索结果解析器 */ +export const baiduEngine: SearchEngine = { + name: "baidu", + + extract(doc: Document): SearchResult[] { + const results: SearchResult[] = []; + + const resultEls = doc.querySelectorAll(".result, .result-op"); + for (const el of Array.from(resultEls)) { + const linkEl = el.querySelector(".t > a, h3 > a"); + const snippetEl = el.querySelector(".c-abstract, .c-span-last"); + if (!linkEl) continue; + + const title = (linkEl.textContent || "").trim(); + const url = linkEl.getAttribute("href") || ""; + const snippet = (snippetEl?.textContent || "").trim(); + + if (title && url) { + results.push({ title, url, snippet }); + } + } + + return results; + }, +}; + +searchEngineRegistry.register(baiduEngine); diff --git a/src/app/service/offscreen/search_engines/bing.test.ts b/src/app/service/offscreen/search_engines/bing.test.ts new file mode 100644 index 000000000..d72a54a04 --- /dev/null +++ b/src/app/service/offscreen/search_engines/bing.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { bingEngine } from "./bing"; + +/** 用 DOMParser 从 HTML 字符串构建 Document */ +function parseHtml(html: string): Document { + return new DOMParser().parseFromString(html, "text/html"); +} + +describe("bingEngine", () => { + it("引擎名为 bing", () => { + expect(bingEngine.name).toBe("bing"); + }); + + it("从标准 Bing HTML 中提取结果", () => { + const html = ` + +
  • +

    Example Title

    +

    Example snippet text

    +
  • +
  • +

    Another Title

    +

    Another snippet

    +
  • + `; + + const results = bingEngine.extract(parseHtml(html)); + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + title: "Example Title", + url: "https://example.com", + snippet: "Example snippet text", + }); + expect(results[1].title).toBe("Another Title"); + expect(results[1].url).toBe("https://another.com"); + }); + + it("没有 .b_algo 元素时返回空数组", () => { + const html = `

    No results

    `; + expect(bingEngine.extract(parseHtml(html))).toEqual([]); + }); + + it("跳过没有链接的 .b_algo 元素", () => { + const html = ` + +
  • +

    No link here

    +
  • + `; + expect(bingEngine.extract(parseHtml(html))).toEqual([]); + }); + + it("snippet 为空时仍返回结果", () => { + const html = ` + +
  • +

    Title Only

    +
  • + `; + const results = bingEngine.extract(parseHtml(html)); + expect(results).toHaveLength(1); + expect(results[0].snippet).toBe(""); + }); +}); diff --git a/src/app/service/offscreen/search_engines/bing.ts b/src/app/service/offscreen/search_engines/bing.ts new file mode 100644 index 000000000..5050ef796 --- /dev/null +++ b/src/app/service/offscreen/search_engines/bing.ts @@ -0,0 +1,30 @@ +import type { SearchEngine, SearchResult } from "./types"; +import { searchEngineRegistry } from "./registry"; + +/** Bing 搜索结果解析器 */ +export const bingEngine: SearchEngine = { + name: "bing", + + extract(doc: Document): SearchResult[] { + const results: SearchResult[] = []; + + const resultEls = doc.querySelectorAll(".b_algo"); + for (const el of Array.from(resultEls)) { + const linkEl = el.querySelector("h2 > a"); + const snippetEl = el.querySelector(".b_caption p, p"); + if (!linkEl) continue; + + const title = (linkEl.textContent || "").trim(); + const url = linkEl.getAttribute("href") || ""; + const snippet = (snippetEl?.textContent || "").trim(); + + if (title && url) { + results.push({ title, url, snippet }); + } + } + + return results; + }, +}; + +searchEngineRegistry.register(bingEngine); diff --git a/src/app/service/offscreen/search_engines/duckduckgo.test.ts b/src/app/service/offscreen/search_engines/duckduckgo.test.ts new file mode 100644 index 000000000..d4a24d8ea --- /dev/null +++ b/src/app/service/offscreen/search_engines/duckduckgo.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { duckduckgoEngine } from "./duckduckgo"; + +/** 用 DOMParser 从 HTML 字符串构建 Document */ +function parseHtml(html: string): Document { + return new DOMParser().parseFromString(html, "text/html"); +} + +describe("duckduckgoEngine", () => { + it("引擎名为 duckduckgo", () => { + expect(duckduckgoEngine.name).toBe("duckduckgo"); + }); + + it("从标准 DuckDuckGo HTML 中提取结果", () => { + const html = ` + +
    + Example Title +
    Example snippet
    +
    +
    + Direct Title +
    Direct snippet
    +
    + `; + + const results = duckduckgoEngine.extract(parseHtml(html)); + expect(results).toHaveLength(2); + // 第一个结果应该解析重定向 URL + expect(results[0].title).toBe("Example Title"); + expect(results[0].url).toBe("https://example.com"); + expect(results[0].snippet).toBe("Example snippet"); + // 第二个没有重定向,保持原始 URL + expect(results[1].url).toBe("https://direct.com"); + }); + + it("没有 .result 元素时返回空数组", () => { + const html = `

    No results

    `; + expect(duckduckgoEngine.extract(parseHtml(html))).toEqual([]); + }); + + it("跳过没有 .result__a 链接的 .result 元素", () => { + const html = ` + + + `; + expect(duckduckgoEngine.extract(parseHtml(html))).toEqual([]); + }); + + it("snippet 为空时仍返回结果", () => { + const html = ` + + + `; + const results = duckduckgoEngine.extract(parseHtml(html)); + expect(results).toHaveLength(1); + expect(results[0].snippet).toBe(""); + }); + + it("uddg 参数解析失败时保留原始 URL", () => { + const html = ` + +
    + Title +
    Snippet
    +
    + `; + const results = duckduckgoEngine.extract(parseHtml(html)); + expect(results).toHaveLength(1); + // URL 不为空(保留了原始值) + expect(results[0].url).toBeTruthy(); + }); +}); diff --git a/src/app/service/offscreen/search_engines/duckduckgo.ts b/src/app/service/offscreen/search_engines/duckduckgo.ts new file mode 100644 index 000000000..0ab4491b4 --- /dev/null +++ b/src/app/service/offscreen/search_engines/duckduckgo.ts @@ -0,0 +1,39 @@ +import type { SearchEngine, SearchResult } from "./types"; +import { searchEngineRegistry } from "./registry"; + +/** DuckDuckGo HTML 搜索结果解析器 */ +export const duckduckgoEngine: SearchEngine = { + name: "duckduckgo", + + extract(doc: Document): SearchResult[] { + const results: SearchResult[] = []; + + const resultEls = doc.querySelectorAll(".result"); + for (const el of Array.from(resultEls)) { + const linkEl = el.querySelector(".result__a"); + const snippetEl = el.querySelector(".result__snippet"); + if (!linkEl) continue; + + const title = (linkEl.textContent || "").trim(); + let url = linkEl.getAttribute("href") || ""; + // DuckDuckGo 使用重定向 URL,提取实际 URL + if (url.includes("uddg=")) { + try { + const urlObj = new URL(url, "https://duckduckgo.com"); + url = decodeURIComponent(urlObj.searchParams.get("uddg") || url); + } catch { + // 保持原始 URL + } + } + const snippet = (snippetEl?.textContent || "").trim(); + + if (title && url) { + results.push({ title, url, snippet }); + } + } + + return results; + }, +}; + +searchEngineRegistry.register(duckduckgoEngine); diff --git a/src/app/service/offscreen/search_engines/index.ts b/src/app/service/offscreen/search_engines/index.ts new file mode 100644 index 000000000..57a830b9b --- /dev/null +++ b/src/app/service/offscreen/search_engines/index.ts @@ -0,0 +1,7 @@ +// 触发所有搜索引擎注册(副作用导入) +import "./bing"; +import "./baidu"; +import "./duckduckgo"; + +export { searchEngineRegistry } from "./registry"; +export type { SearchEngine, SearchResult } from "./types"; diff --git a/src/app/service/offscreen/search_engines/registry.test.ts b/src/app/service/offscreen/search_engines/registry.test.ts new file mode 100644 index 000000000..efb5091c4 --- /dev/null +++ b/src/app/service/offscreen/search_engines/registry.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { SearchEngineRegistry } from "./registry"; +import type { SearchEngine } from "./types"; + +// 单独导出 class 供测试用,这里通过重新实例化来隔离测试 +// 由于 registry.ts 导出的是单例,测试中构造新实例保持隔离 +describe("SearchEngineRegistry", () => { + let registry: SearchEngineRegistry; + + beforeEach(() => { + registry = new SearchEngineRegistry(); + }); + + it("初始状态下引擎列表为空", () => { + expect(registry.listNames()).toEqual([]); + }); + + it("注册引擎后可通过名称获取", () => { + const engine: SearchEngine = { + name: "test", + extract: () => [], + }; + registry.register(engine); + expect(registry.get("test")).toBe(engine); + }); + + it("listNames 返回所有已注册引擎名", () => { + registry.register({ name: "a", extract: () => [] }); + registry.register({ name: "b", extract: () => [] }); + expect(registry.listNames()).toContain("a"); + expect(registry.listNames()).toContain("b"); + expect(registry.listNames()).toHaveLength(2); + }); + + it("同名注册时覆盖旧引擎", () => { + const engine1: SearchEngine = { name: "bing", extract: () => [] }; + const engine2: SearchEngine = { name: "bing", extract: () => [{ title: "x", url: "y", snippet: "z" }] }; + registry.register(engine1); + registry.register(engine2); + expect(registry.get("bing")).toBe(engine2); + expect(registry.listNames()).toHaveLength(1); + }); + + it("获取未注册引擎返回 undefined", () => { + expect(registry.get("nonexistent")).toBeUndefined(); + }); +}); diff --git a/src/app/service/offscreen/search_engines/registry.ts b/src/app/service/offscreen/search_engines/registry.ts new file mode 100644 index 000000000..1b9ad0278 --- /dev/null +++ b/src/app/service/offscreen/search_engines/registry.ts @@ -0,0 +1,23 @@ +import type { SearchEngine } from "./types"; + +/** 搜索引擎插件注册表,支持运行时注册与查找 */ +export class SearchEngineRegistry { + private engines = new Map(); + + /** 注册一个搜索引擎(同名覆盖) */ + register(engine: SearchEngine): void { + this.engines.set(engine.name, engine); + } + + /** 按名称获取引擎,未找到返回 undefined */ + get(name: string): SearchEngine | undefined { + return this.engines.get(name); + } + + /** 返回已注册的所有引擎名列表 */ + listNames(): string[] { + return Array.from(this.engines.keys()); + } +} + +export const searchEngineRegistry = new SearchEngineRegistry(); diff --git a/src/app/service/offscreen/search_engines/types.ts b/src/app/service/offscreen/search_engines/types.ts new file mode 100644 index 000000000..a23b3f71c --- /dev/null +++ b/src/app/service/offscreen/search_engines/types.ts @@ -0,0 +1,20 @@ +/** 搜索结果条目(通用格式) */ +export interface SearchResult { + title: string; + url: string; + snippet: string; +} + +/** + * 搜索引擎解析器接口。 + * 每个引擎独立实现此接口,由 searchEngineRegistry 统一管理。 + */ +export interface SearchEngine { + /** 引擎名(全局唯一,用于注册与查找) */ + readonly name: string; + /** + * 解析已解析好的 Document,返回搜索结果数组。 + * DOM 解析由外层统一完成,避免各引擎重复 DOMParser 实例化。 + */ + extract(document: Document): SearchResult[]; +} diff --git a/src/app/service/sandbox/runtime.ts b/src/app/service/sandbox/runtime.ts index 3ceff0654..75202e918 100644 --- a/src/app/service/sandbox/runtime.ts +++ b/src/app/service/sandbox/runtime.ts @@ -13,6 +13,7 @@ import { CronJob } from "cron"; import { proxyUpdateRunStatus } from "../offscreen/client"; import { BgExecScriptWarp } from "../content/exec_warp"; import type ExecScript from "../content/exec_script"; +import { compileScriptCodeByResource } from "../content/utils"; import type { ValueUpdateDataEncoded } from "../content/types"; import { getStorageName, getMetadataStr, getUserConfigStr, getISOWeek } from "@App/pkg/utils/utils"; import type { EmitEventRequest, ScriptLoadInfo } from "../service_worker/types"; @@ -346,6 +347,67 @@ export class Runtime { this.api.on("runtime/valueUpdate", this.valueUpdate.bind(this)); this.api.on("runtime/emitEvent", this.emitEvent.bind(this)); this.api.on("setSandboxLanguage", this.setSandboxLanguage.bind(this)); + this.api.on("executeSkillScript", this.executeSkillScript.bind(this)); initLanguage(); } + + // 执行 Skill Script:构建最小化脚本上下文,注入 args,执行并返回结果 + async executeSkillScript(params: { + uuid: string; + code: string; + args: Record; + grants: string[]; + name: string; + requires?: Array<{ url: string; content: string }>; + configValues?: Record; + }): Promise { + const uuid = params.uuid; + const metadata: any = { + grant: params.grants, + }; + // 通过 compileScriptCodeByResource 包裹代码,加上 with(arguments[0]||this.$) 等上下文绑定, + // 使脚本能访问 sandboxContext 上注入的变量(args、GM API 等) + const compiledCode = compileScriptCodeByResource({ + name: params.name, + code: params.code, + require: params.requires || [], + }); + + // 构造最小化的 ScriptLoadInfo + const scriptLoadInfo = { + uuid, + name: params.name, + namespace: "", + type: SCRIPT_TYPE_BACKGROUND, + status: 1, + sort: 0, + runStatus: "complete" as const, + createtime: Date.now(), + checktime: 0, + code: compiledCode, + value: {}, + flag: "", + resource: {}, + metadata, + originalMetadata: metadata, + metadataStr: "", + userConfigStr: "", + } as ScriptLoadInfo; + + // 使用 BgExecScriptWarp 执行,它会自动构建 setTimeout/setInterval 等 + const exec = new BgExecScriptWarp(scriptLoadInfo, this.windowMessage); + // 通过 sandboxContext 注入 args(BgExecScriptWarp 通过 globalInjection 注入了 setTimeout 等, + // 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 { + const result = await exec.exec(); + return result; + } finally { + exec.stop(); + } + } } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 2270314d7..d31acb247 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -11,6 +11,8 @@ import { type FileSystemType } from "@Packages/filesystem/factory"; import { type ResourceBackup } from "@App/pkg/backup/struct"; import { type VSCodeConnectParam } from "../offscreen/vscode-connect"; import { type ScriptInfo } from "@App/pkg/utils/scriptInstall"; +import type { AgentModelConfig, MCPApiRequest, SkillConfigField } from "@App/app/service/agent/core/types"; +import type { SearchEngineConfig } from "@App/app/service/agent/core/tools/search_config"; import type { ScriptService, TCheckScriptUpdateOption, @@ -328,3 +330,112 @@ export class SystemClient extends Client { return this.do("connectVSCode", params); } } + +export class AgentClient extends Client { + constructor(msgSender: MessageSend) { + super(msgSender, "serviceWorker/agent"); + } + + installSkill(params: { + skillMd: string; + scripts?: Array<{ name: string; code: string }>; + references?: Array<{ name: string; content: string }>; + }): Promise { + return this.do("installSkill", params); + } + + removeSkill(name: string): Promise { + return this.do("removeSkill", name); + } + + refreshSkill(name: string): Promise { + return this.doThrow("refreshSkill", name); + } + + setSkillEnabled(name: string, enabled: boolean): Promise { + return this.doThrow("setSkillEnabled", { name, enabled }); + } + + prepareSkillInstall(zipBase64: string): Promise { + return this.doThrow("prepareSkillInstall", zipBase64); + } + + getSkillInstallData(uuid: string): Promise<{ + skillMd: string; + metadata: { + name: string; + description: string; + config?: Record; + }; + prompt: string; + scripts: Array<{ name: string; code: string }>; + references: Array<{ name: string; content: string }>; + isUpdate: boolean; + }> { + return this.doThrow("getSkillInstallData", uuid); + } + + completeSkillInstall(uuid: string): Promise { + return this.doThrow("completeSkillInstall", uuid); + } + + 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); + } + + // Model CRUD + listModels(): Promise { + return this.doThrow("listModels"); + } + + getModel(id: string) { + return this.do("getModel", id); + } + + saveModel(model: AgentModelConfig) { + return this.do("saveModel", model); + } + + removeModel(id: string) { + return this.do("removeModel", id); + } + + getDefaultModelId(): Promise { + return this.doThrow("getDefaultModelId"); + } + + setDefaultModelId(id: string) { + return this.do("setDefaultModelId", id); + } + + // 摘要模型(未设置时返回空字符串,不能用 doThrow) + getSummaryModelId(): Promise { + return this.do("getSummaryModelId").then((id) => id || ""); + } + + setSummaryModelId(id: string) { + return this.do("setSummaryModelId", id); + } + + // 搜索配置 + getSearchConfig(): Promise { + return this.doThrow("getSearchConfig"); + } + + saveSearchConfig(config: SearchEngineConfig) { + return this.do("saveSearchConfig", config); + } + + // MCP API + mcpApi(request: MCPApiRequest): Promise { + return this.doThrow("mcpApi", request); + } +} diff --git a/src/app/service/service_worker/gm_api/gm_agent.ts b/src/app/service/service_worker/gm_api/gm_agent.ts new file mode 100644 index 000000000..717ae3b6c --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_agent.ts @@ -0,0 +1,69 @@ +// Agent API 方法,通过 PermissionVerify.API 装饰器注册到全局 API Map +// 运行时 this 绑定为 GMApi 实例(由 handlerRequest 中的 api.api.call(this, ...) 实现) + +import type { IGetSender } from "@Packages/message/server"; +import type { ConfirmParam } from "../permission_verify"; +import PermissionVerify, { type ApiParamConfirmFn } from "../permission_verify"; +import type { GMApiRequest } from "../types"; +import type { ConversationApiRequest } from "@App/app/service/agent/core/types"; +import i18next, { i18nName } from "@App/locales/locales"; +import type GMApi from "./gm_api"; + +// Agent API 共用的权限确认逻辑 +const agentConfirm: ApiParamConfirmFn = async (request: GMApiRequest, _sender: IGetSender, gmApi: GMApi) => { + const ret = await gmApi.permissionVerify.queryPermission(request, { + permission: "agent.conversation", + }); + if (ret && ret.allow) return true; + const metadata: { [key: string]: string } = {}; + metadata[i18next.t("script_name")] = i18nName(request.script); + return { + permission: "agent.conversation", + title: i18next.t("agent_permission_title"), + metadata, + describe: i18next.t("agent_permission_describe"), + permissionContent: i18next.t("agent_permission_content"), + } as ConfirmParam; +}; + +// 独立类,仅用于承载装饰器注册 +// 方法在运行时通过 call(gmApiInstance, ...) 执行,this 指向 GMApi +class GMAgentApi { + @PermissionVerify.API({ + link: ["CAT.agent.conversation"], + confirm: agentConfirm, + dotAlias: false, + }) + CAT_agentConversation(this: GMApi, request: GMApiRequest<[ConversationApiRequest]>, _sender: IGetSender) { + if (!this.agentService) { + throw new Error("AgentService is not available"); + } + return this.agentService.handleConversationApi(request.params[0]); + } + + @PermissionVerify.API({ + link: ["CAT.agent.conversation"], + confirm: agentConfirm, + dotAlias: false, + }) + async CAT_agentConversationChat(this: GMApi, request: GMApiRequest<[any]>, sender: IGetSender) { + if (!this.agentService) { + throw new Error("AgentService is not available"); + } + return this.agentService.handleConversationChatFromGmApi(request.params[0], sender); + } + + @PermissionVerify.API({ + link: ["CAT.agent.conversation"], + confirm: agentConfirm, + dotAlias: false, + }) + async CAT_agentAttachToConversation(this: GMApi, request: GMApiRequest<[any]>, sender: IGetSender) { + if (!this.agentService) { + throw new Error("AgentService is not available"); + } + return this.agentService.handleAttachToConversationFromGmApi(request.params[0], sender); + } +} + +export default GMAgentApi; diff --git a/src/app/service/service_worker/gm_api/gm_agent_dom.ts b/src/app/service/service_worker/gm_api/gm_agent_dom.ts new file mode 100644 index 000000000..16878bee8 --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_agent_dom.ts @@ -0,0 +1,43 @@ +// Agent DOM API 方法,通过 PermissionVerify.API 装饰器注册到全局 API Map +// 运行时 this 绑定为 GMApi 实例 + +import type { IGetSender } from "@Packages/message/server"; +import type { ConfirmParam } from "../permission_verify"; +import PermissionVerify, { type ApiParamConfirmFn } from "../permission_verify"; +import type { GMApiRequest } from "../types"; +import type { DomApiRequest } from "@App/app/service/agent/core/types"; +import i18next, { i18nName } from "@App/locales/locales"; +import type GMApi from "./gm_api"; + +// DOM API 权限确认逻辑 +const agentDomConfirm: ApiParamConfirmFn = async (request: GMApiRequest, _sender: IGetSender, gmApi: GMApi) => { + const ret = await gmApi.permissionVerify.queryPermission(request, { + permission: "agent.dom", + }); + if (ret && ret.allow) return true; + const metadata: { [key: string]: string } = {}; + metadata[i18next.t("script_name")] = i18nName(request.script); + return { + permission: "agent.dom", + title: i18next.t("agent_dom_permission_title"), + metadata, + describe: i18next.t("agent_dom_permission_describe"), + permissionContent: i18next.t("agent_dom_permission_content"), + } as ConfirmParam; +}; + +class GMAgentDomApi { + @PermissionVerify.API({ + link: ["CAT.agent.dom"], + confirm: agentDomConfirm, + dotAlias: false, + }) + CAT_agentDom(this: GMApi, request: GMApiRequest<[DomApiRequest]>, _sender: IGetSender) { + if (!this.agentService) { + throw new Error("AgentService is not available"); + } + return this.agentService.handleDomApi(request.params[0]); + } +} + +export default GMAgentDomApi; diff --git a/src/app/service/service_worker/gm_api/gm_agent_model.ts b/src/app/service/service_worker/gm_api/gm_agent_model.ts new file mode 100644 index 000000000..e9d81cc72 --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_agent_model.ts @@ -0,0 +1,45 @@ +// CAT.agent.model API 方法,通过 PermissionVerify.API 装饰器注册到全局 API Map +// 运行时 this 绑定为 GMApi 实例 +// 只读操作,权限确认走缓存+DB + +import type { IGetSender } from "@Packages/message/server"; +import type { ConfirmParam } from "../permission_verify"; +import PermissionVerify, { type ApiParamConfirmFn } from "../permission_verify"; +import type { GMApiRequest } from "../types"; +import type { ModelApiRequest } from "@App/app/service/agent/core/types"; +import i18next, { i18nName } from "@App/locales/locales"; +import type GMApi from "./gm_api"; + +// 只读操作,缓存 + DB 查询权限 +const agentConfirm: ApiParamConfirmFn = async (request: GMApiRequest, _sender: IGetSender, gmApi: GMApi) => { + const ret = await gmApi.permissionVerify.queryPermission(request, { + permission: "agent.model", + }); + if (ret && ret.allow) return true; + + const metadata: { [key: string]: string } = {}; + metadata[i18next.t("script_name")] = i18nName(request.script); + return { + permission: "agent.model", + title: i18next.t("agent_permission_title"), + metadata, + describe: i18next.t("agent_permission_describe"), + permissionContent: i18next.t("agent_permission_content"), + } as ConfirmParam; +}; + +class GMAgentModelApi { + @PermissionVerify.API({ + link: ["CAT.agent.model"], + confirm: agentConfirm, + dotAlias: false, + }) + CAT_agentModel(this: GMApi, request: GMApiRequest<[ModelApiRequest]>, _sender: IGetSender) { + if (!this.agentService) { + throw new Error("AgentService is not available"); + } + return this.agentService.handleModelApi(request.params[0]); + } +} + +export default GMAgentModelApi; diff --git a/src/app/service/service_worker/gm_api/gm_agent_opfs.ts b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts new file mode 100644 index 000000000..aa3bcb454 --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts @@ -0,0 +1,57 @@ +// OPFS API 方法,通过 PermissionVerify.API 装饰器注册到全局 API Map +// 运行时 this 绑定为 GMApi 实例 + +import type { IGetSender } from "@Packages/message/server"; +import type { ConfirmParam } from "../permission_verify"; +import PermissionVerify, { type ApiParamConfirmFn } from "../permission_verify"; +import type { GMApiRequest } from "../types"; +import type { OPFSApiRequest } from "@App/app/service/agent/core/types"; +import i18next, { i18nName } from "@App/locales/locales"; +import type GMApi from "./gm_api"; + +// 写操作(write/delete)需确认;读操作(read/list)缓存+DB +const agentConfirm: ApiParamConfirmFn = async (request: GMApiRequest, _sender: IGetSender, gmApi: GMApi) => { + const opfsReq = request.params[0] as OPFSApiRequest; + const isWrite = opfsReq.action === "write" || opfsReq.action === "delete"; + + if (isWrite) { + // 写操作:仅查询 DB 中的持久化授权,跳过缓存 + const ret = await gmApi.permissionVerify.queryPersistentPermission(request, { + permission: "agent.opfs", + }); + if (ret && ret.allow) return true; + } else { + // 读操作:缓存 + DB + const ret = await gmApi.permissionVerify.queryPermission(request, { + permission: "agent.opfs", + }); + if (ret && ret.allow) return true; + } + + const metadata: { [key: string]: string } = {}; + metadata[i18next.t("script_name")] = i18nName(request.script); + return { + permission: "agent.opfs", + title: i18next.t("agent_permission_title"), + metadata, + describe: i18next.t("agent_permission_describe"), + permissionContent: i18next.t("agent_permission_content"), + persistentOnly: isWrite, + } as ConfirmParam; +}; + +class GMAgentOPFSApi { + @PermissionVerify.API({ + link: ["CAT.agent.opfs"], + confirm: agentConfirm, + dotAlias: false, + }) + CAT_agentOPFS(this: GMApi, request: GMApiRequest<[OPFSApiRequest]>, sender: IGetSender) { + if (!this.agentService) { + throw new Error("AgentService is not available"); + } + return this.agentService.handleOPFSApi(request.params[0], sender); + } +} + +export default GMAgentOPFSApi; diff --git a/src/app/service/service_worker/gm_api/gm_agent_skills.ts b/src/app/service/service_worker/gm_api/gm_agent_skills.ts new file mode 100644 index 000000000..d8e13df48 --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_agent_skills.ts @@ -0,0 +1,57 @@ +// Skill API 方法,通过 PermissionVerify.API 装饰器注册到全局 API Map +// 运行时 this 绑定为 GMApi 实例 + +import type { IGetSender } from "@Packages/message/server"; +import type { ConfirmParam } from "../permission_verify"; +import PermissionVerify, { type ApiParamConfirmFn } from "../permission_verify"; +import type { GMApiRequest } from "../types"; +import type { SkillApiRequest } from "@App/app/service/agent/core/types"; +import i18next, { i18nName } from "@App/locales/locales"; +import type GMApi from "./gm_api"; + +// 写操作(install/remove)每次都需弹窗确认 +const agentConfirm: ApiParamConfirmFn = async (request: GMApiRequest, _sender: IGetSender, gmApi: GMApi) => { + const skillsReq = request.params[0] as SkillApiRequest; + const isWrite = skillsReq.action === "install" || skillsReq.action === "remove" || skillsReq.action === "call"; + + if (isWrite) { + // 写操作:仅查询 DB 中的持久化授权,跳过缓存 + const ret = await gmApi.permissionVerify.queryPersistentPermission(request, { + permission: "agent.skills", + }); + if (ret && ret.allow) return true; + } else { + // 读操作:缓存 + DB + const ret = await gmApi.permissionVerify.queryPermission(request, { + permission: "agent.skills", + }); + if (ret && ret.allow) return true; + } + + const metadata: { [key: string]: string } = {}; + metadata[i18next.t("script_name")] = i18nName(request.script); + return { + permission: "agent.skills", + title: i18next.t("agent_permission_title"), + metadata, + describe: i18next.t("agent_permission_describe"), + permissionContent: i18next.t("agent_permission_content"), + persistentOnly: isWrite, + } as ConfirmParam; +}; + +class GMAgentSkillsApi { + @PermissionVerify.API({ + link: ["CAT.agent.skills"], + confirm: agentConfirm, + dotAlias: false, + }) + CAT_agentSkills(this: GMApi, request: GMApiRequest<[SkillApiRequest]>, _sender: IGetSender) { + if (!this.agentService) { + throw new Error("AgentService is not available"); + } + return this.agentService.handleSkillsApi(request.params[0]); + } +} + +export default GMAgentSkillsApi; diff --git a/src/app/service/service_worker/gm_api/gm_agent_task.ts b/src/app/service/service_worker/gm_api/gm_agent_task.ts new file mode 100644 index 000000000..2dcd98898 --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_agent_task.ts @@ -0,0 +1,43 @@ +// Agent Task API 方法,通过 PermissionVerify.API 装饰器注册到全局 API Map +// 运行时 this 绑定为 GMApi 实例 + +import type { IGetSender } from "@Packages/message/server"; +import type { ConfirmParam } from "../permission_verify"; +import PermissionVerify, { type ApiParamConfirmFn } from "../permission_verify"; +import type { GMApiRequest } from "../types"; +import type { AgentTaskApiRequest } from "@App/app/service/agent/core/types"; +import i18next, { i18nName } from "@App/locales/locales"; +import type GMApi from "./gm_api"; + +// 复用 Agent API 的权限确认逻辑 +const agentTaskConfirm: ApiParamConfirmFn = async (request: GMApiRequest, _sender: IGetSender, gmApi: GMApi) => { + const ret = await gmApi.permissionVerify.queryPermission(request, { + permission: "agent.task", + }); + if (ret && ret.allow) return true; + const metadata: { [key: string]: string } = {}; + metadata[i18next.t("script_name")] = i18nName(request.script); + return { + permission: "agent.task", + title: i18next.t("agent_permission_title"), + metadata, + describe: i18next.t("agent_permission_describe"), + permissionContent: i18next.t("agent_permission_content"), + } as ConfirmParam; +}; + +class GMAgentTaskApi { + @PermissionVerify.API({ + link: ["CAT.agent.task"], + confirm: agentTaskConfirm, + dotAlias: false, + }) + CAT_agentTask(this: GMApi, request: GMApiRequest<[AgentTaskApiRequest]>, _sender: IGetSender) { + if (!this.agentService) { + throw new Error("AgentService is not available"); + } + return this.agentService.handleAgentTaskApi(request.params[0]); + } +} + +export default GMAgentTaskApi; 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..cdb68f3c6 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,19 @@ 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_agentSkills", "CAT_agentDom"]; + for (const name of agentApis) { + expect(PermissionVerifyApiGet(name), `${name} 应已注册`).toBeDefined(); + } + }); +}); 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 755096d21..243ea03ac 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1,6 +1,6 @@ import LoggerCore from "@App/app/logger/core"; import Logger from "@App/app/logger/logger"; -import { ScriptDAO } from "@App/app/repo/scripts"; +import { ScriptDAO, type Script } from "@App/app/repo/scripts"; import { SubscribeDAO } from "@App/app/repo/subscribe"; import { type IGetSender, type Group, GetSenderType } from "@Packages/message/server"; import type { ExtMessageSender, MessageSend, TMessageCommAction } from "@Packages/message/types"; @@ -37,6 +37,11 @@ import type { import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue"; import type { NotificationOptionCache } from "../utils"; import { BrowserNoSupport, notificationsUpdate } from "../utils"; +import { + getSkillScriptGrantsByUuid, + getSkillScriptNameByUuid, + SKILL_SCRIPT_UUID_PREFIX, +} from "@App/app/service/agent/core/skill_script_executor"; import i18n from "@App/locales/locales"; import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { createObjectURL } from "../../offscreen/client"; @@ -54,6 +59,21 @@ import { headerModifierMap, headersReceivedMap } from "./gm_xhr"; import { BgGMXhr } from "@App/pkg/utils/xhr/bg_gm_xhr"; import { mightPrepareSetClipboard, setClipboard } from "../clipboard"; import { nativePageWindowOpen } from "../../offscreen/gm_api"; +import type { AgentService } from "@App/app/service/agent/service_worker/agent"; +// 导入 Agent API 以触发装饰器注册 +// 注意:不能使用 import "./gm_agent",sideEffects 配置会导致 tree-shaking 移除纯副作用导入 +import GMAgentApi from "./gm_agent"; +void GMAgentApi; +import GMAgentSkillsApi from "./gm_agent_skills"; +void GMAgentSkillsApi; +import GMAgentDomApi from "./gm_agent_dom"; +void GMAgentDomApi; +import GMAgentTaskApi from "./gm_agent_task"; +void GMAgentTaskApi; +import GMAgentModelApi from "./gm_agent_model"; +void GMAgentModelApi; +import GMAgentOPFSApi from "./gm_agent_opfs"; +void GMAgentOPFSApi; let generatedUniqueMarkerIDs = ""; let generatedUniqueMarkerIDWhen = ""; @@ -247,9 +267,11 @@ export default class GMApi { scriptDAO: ScriptDAO = new ScriptDAO(); subscribeDAO: SubscribeDAO = new SubscribeDAO(); + agentService?: AgentService; + constructor( private systemConfig: SystemConfig, - private permissionVerify: PermissionVerify, + public permissionVerify: PermissionVerify, private group: Group, private msgSender: MessageSend, private mq: IMessageQueue, @@ -259,6 +281,10 @@ export default class GMApi { this.logger = LoggerCore.logger().with({ service: "runtime/gm_api" }); } + setAgentService(agentService: AgentService) { + this.agentService = agentService; + } + // PermissionVerify.API // sendMessage from Content Script, etc async handlerRequest(data: MessageRequest, sender: IGetSender) { @@ -279,9 +305,32 @@ export default class GMApi { // 解析请求 async parseRequest(data: MessageRequest): Promise> { - const script = await this.scriptDAO.get(data.uuid); - if (!script) { - throw new Error("script is not found"); + let script; + if (data.uuid.startsWith(SKILL_SCRIPT_UUID_PREFIX)) { + // Skill Script GM API 调用:构造虚拟 Script 对象(Skill Script 不在 ScriptDAO 中) + // 直接从 UUID map 获取 grants,避免查 repo(skill 的 Skill Script 不在 skillScriptRepo 中) + const grants = getSkillScriptGrantsByUuid(data.uuid); + const toolName = getSkillScriptNameByUuid(data.uuid); + // 使用基于工具名的稳定标识符,使权限缓存在多次执行间有效 + // 每次执行生成新的临时 UUID,但权限应绑定到工具本身而非单次执行 + const stableUuid = SKILL_SCRIPT_UUID_PREFIX + toolName; + script = { + uuid: stableUuid, + name: toolName || data.uuid, + namespace: "", + metadata: { grant: grants }, + type: 3, + status: 1, + sort: 0, + runStatus: "running" as const, + createtime: Date.now(), + checktime: 0, + } as Script; + } else { + script = await this.scriptDAO.get(data.uuid); + if (!script) { + throw new Error("script is not found"); + } } // 订阅脚本的 connect 使用订阅声明的 connect 覆盖脚本自身的 if (script.subscribeUrl) { @@ -290,7 +339,9 @@ export default class GMApi { script.metadata = { ...script.metadata, connect: subscribe.metadata.connect }; } } - return { ...data, script } as GMApiRequest; + // Skill Script 使用稳定标识符覆盖 uuid,确保权限 DB 查询/保存也用同一个标识符 + const uuid = data.uuid.startsWith(SKILL_SCRIPT_UUID_PREFIX) ? script.uuid : data.uuid; + return { ...data, uuid, script } as GMApiRequest; } @PermissionVerify.API({ diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index 72ddd111d..56c9d1efc 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 "@App/app/service/agent/service_worker/agent"; // service worker的管理器 export default class ServiceWorkerManager { @@ -101,6 +102,14 @@ export default class ServiceWorkerManager { faviconDAO ); system.init(); + const agent = new AgentService(this.api.group("agent"), this.sender, resource); + agent.init(); + + // 注入 AgentService 到 GMApi,使 Agent API 走权限验证通道 + const gmApi = runtime.getGMApi(); + if (gmApi) { + gmApi.setAgentService(agent); + } const regularScriptUpdateCheck = async () => { const res = await onRegularUpdateCheckAlarm(systemConfig, script, subscribe); @@ -152,6 +161,9 @@ export default class ServiceWorkerManager { // 检查扩展更新 regularExtensionUpdateCheck(); break; + case "agentTaskScheduler": + agent.onSchedulerTick(); + break; } }); // 12小时检查一次扩展更新 @@ -180,6 +192,29 @@ export default class ServiceWorkerManager { } }); + // Agent 定时任务调度器 alarm(每分钟触发一次) + chrome.alarms.get("agentTaskScheduler", (alarm) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.alarms.get:", lastError); + } + if (!alarm) { + chrome.alarms.create( + "agentTaskScheduler", + { + delayInMinutes: 1, + periodInMinutes: 1, + }, + () => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.alarms.create:", lastError); + } + } + ); + } + }); + // 云同步 systemConfig.watch("cloud_sync", (value) => { synchronize.cloudSyncConfigChange(value); diff --git a/src/app/service/service_worker/permission_verify.ts b/src/app/service/service_worker/permission_verify.ts index 4f8510261..97b4eb7f2 100644 --- a/src/app/service/service_worker/permission_verify.ts +++ b/src/app/service/service_worker/permission_verify.ts @@ -28,6 +28,8 @@ export interface ConfirmParam { wildcard?: boolean; // 权限内容 permissionContent?: string; + // 仅接受持久化(DB 存储)的授权,忽略临时缓存 + persistentOnly?: boolean; } export interface UserConfirm { @@ -196,6 +198,21 @@ export default class PermissionVerify { return `${CACHE_KEY_PERMISSION}${request.script.uuid}:${confirm.permission}:${confirm.permissionValue || ""}`; } + // 仅查询 DB 中的持久化权限,跳过缓存 + async queryPersistentPermission( + request: GMApiRequest, + confirm: { + permission: string; + permissionValue?: string; + } + ): Promise { + let model = await this.permissionDAO.findByKey(request.uuid, confirm.permission, confirm.permissionValue || ""); + if (!model) { + model = await this.permissionDAO.findByKey(request.uuid, confirm.permission, "*"); + } + return model; + } + async queryPermission( request: GMApiRequest, confirm: { @@ -222,15 +239,26 @@ export default class PermissionVerify { if (typeof confirm === "boolean") { return confirm; } - const ret = await this.queryPermission(request, confirm); - // 有查询到结果,进入判断,不再需要用户确认 - if (ret) { - if (ret.allow) { - return true; + + if (confirm.persistentOnly) { + // 仅查询 DB,跳过缓存 + const ret = await this.queryPersistentPermission(request, confirm); + if (ret) { + if (ret.allow) return true; + throw new Error("permission denied"); + } + } else { + const ret = await this.queryPermission(request, confirm); + // 有查询到结果,进入判断,不再需要用户确认 + if (ret) { + if (ret.allow) { + return true; + } + // 权限拒绝 + throw new Error("permission denied"); } - // 权限拒绝 - throw new Error("permission denied"); } + // 没有权限,则弹出页面让用户进行确认 const userConfirm = await this.confirmWindow(request.script, confirm, sender); // 成功存入数据库 @@ -257,12 +285,12 @@ export default class PermissionVerify { default: break; } - // 临时 放入缓存 - if (userConfirm.type >= 2) { + // persistentOnly 模式:type 2-3 不缓存(等同于 type 1 的一次性允许) + if (!confirm.persistentOnly && userConfirm.type >= 2) { const cacheKey = this.buildCacheKey(request, confirm); cacheInstance.set(cacheKey, model); } - // 总是 放入数据库 + // 总是 放入数据库(type 4-5 为永久授权) if (userConfirm.type >= 4) { const oldConfirm = await this.permissionDAO.findByKey(model.uuid, model.permission, model.permissionValue); if (!oldConfirm) { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 8ddc0dc00..e5b64a31b 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -89,6 +89,11 @@ export class RuntimeService { scriptMatchEnable: UrlMatch = new UrlMatch(); scriptMatchDisable: UrlMatch = new UrlMatch(); blackMatch: UrlMatch = new UrlMatch(); + private gmApi?: GMApi; + + getGMApi(): GMApi | undefined { + return this.gmApi; + } logger: Logger; @@ -369,7 +374,7 @@ export class RuntimeService { init() { // 启动gm api const permission = new PermissionVerify(this.group.group("permission"), this.mq); - const gmApi = new GMApi( + this.gmApi = new GMApi( this.systemConfig, permission, this.group, @@ -379,7 +384,7 @@ export class RuntimeService { new GMExternalDependencies(this) ); permission.init(); - gmApi.start(); + this.gmApi.start(); this.group.on("stopScript", this.stopScript.bind(this)); this.group.on("runScript", this.runScript.bind(this)); diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index ca6b6da2b..7794c32e7 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -47,6 +47,7 @@ import { getSimilarityScore, ScriptUpdateCheck } from "./script_update_check"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import { CompiledResourceDAO } from "@App/app/repo/resource"; import { initRegularUpdateCheck } from "./regular_updatecheck"; +import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; export type TCheckScriptUpdateOption = Partial< { checkType: "user"; noUpdateCheck?: number } | ({ checkType: "system" } & Record) @@ -98,8 +99,8 @@ export class ScriptService { } // 处理url, 实现安装脚本 let targetUrl: string; - // 判断是否为 file:///*/*.user.js - if (req.url.startsWith("file://") && req.url.endsWith(".user.js")) { + // 判断是否为 file:///*/*.user.js 或 file:///*/*.skill.js + if (req.url.startsWith("file://") && (req.url.endsWith(".user.js") || req.url.endsWith(".skill.js"))) { targetUrl = req.url; } else { const reqUrl = new URL(req.url); @@ -166,6 +167,7 @@ export class ScriptService { { schemes: ["http", "https"], hostEquals: "docs.scriptcat.org", pathPrefix: "/en/docs/script_installation/" }, { schemes: ["http", "https"], hostEquals: "www.tampermonkey.net", pathPrefix: "/script_installation.php" }, { schemes: ["file"], pathSuffix: ".user.js" }, + { schemes: ["file"], pathSuffix: ".skill.js" }, ], } ); @@ -250,6 +252,14 @@ export class ScriptService { isUrlFilterCaseSensitive: false, requestDomains: ["bitbucket.org"], // Chrome 101+ }, + // SkillScript (.skill.js) 安装检测 + { + regexFilter: "^([^?#]+?\\.skill\\.js)", + resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME], + requestMethods: ["get" as chrome.declarativeNetRequest.RequestMethod], + isUrlFilterCaseSensitive: false, + excludedRequestDomains: ["github.com", "gitlab.com", "gitea.com", "bitbucket.org"], + }, ]; const installPageURL = chrome.runtime.getURL("src/install.html"); const rules = conditions.map((condition, idx) => { @@ -906,6 +916,18 @@ export class ScriptService { logger?.error("prepare script failed", Logger.E(e)); } } + // 检测是否为 SkillScript + const skillScriptMeta = parseSkillScriptMetadata(code); + if (skillScriptMeta) { + const si = [ + false, + { uuid, code, url, source: upsertBy, metadata: {}, userSubscribe: false, skillScript: true } as ScriptInfo, + options, + ]; + await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, si); + return 1; + } + const metadata = parseMetadata(code); if (!metadata) { throw new Error("parse script info failed"); diff --git a/src/locales/ach-UG/translation.json b/src/locales/ach-UG/translation.json index e18052367..3d5344e3b 100644 --- a/src/locales/ach-UG/translation.json +++ b/src/locales/ach-UG/translation.json @@ -571,5 +571,150 @@ "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_copy": "Copy", + "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_max_tokens": "Max Output Tokens", + "agent_model_no_models": "No models configured", + "agent_model_vision_support": "Supports vision input", + "agent_model_image_output": "Supports image output", + "agent_model_capabilities": "Capabilities", + "agent_model_supports_vision": "Vision Input", + "agent_model_supports_image_output": "Image Output", + "agent_coming_soon": "Coming soon...", + "agent_skills_title": "Skills Management", + "agent_skills_add": "Add Skill", + "agent_skills_empty": "No skills installed", + "agent_skills_tools": "Tools", + "agent_skills_references": "References", + "agent_skills_detail": "Skill Details", + "agent_skills_edit_prompt": "Prompt", + "agent_skills_install": "Install Skill", + "agent_skills_install_url": "Import from URL", + "agent_skills_install_paste": "Paste SKILL.md", + "agent_skills_uninstall": "Uninstall", + "agent_skills_uninstall_confirm": "Are you sure to uninstall Skill \"{{name}}\"?", + "agent_skills_save_success": "Saved successfully", + "agent_skills_install_success": "Installed successfully", + "agent_skills_fetch_failed": "Fetch failed", + "agent_skills_add_script": "Add Script", + "agent_skills_add_reference": "Add Reference", + "agent_skills_install_zip": "Upload ZIP", + "agent_skills_install_zip_hint": "Click to select a .zip file", + "agent_skills_prompt": "Prompt", + "agent_skills_installed_at": "Installed at", + "agent_skills_refresh": "Refresh", + "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_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...", + "agent_chat_newline": "for new line", + "agent_chat_welcome_hint": "Ask me anything about your scripts", + "agent_chat_welcome_start": "Create a conversation to get started", + "agent_chat_tokens": "tokens", + "agent_chat_first_token": "TTFT", + "agent_chat_tools_count": "{{count}} tools", + "agent_chat_tools_enabled": "Tools enabled", + "agent_chat_tools_disabled": "Tools disabled", + "agent_chat_tools_enabled_tip": "Tools enabled — click to disable", + "agent_chat_tools_disabled_tip": "Tools disabled — click to enable", + "agent_chat_background_enabled_tip": "Background mode ON — conversation keeps running after closing the page, click to disable", + "agent_chat_background_disabled_tip": "Background mode OFF — conversation stops when the page is closed, click to enable", + "agent_chat_delete_round": "Delete", + "agent_chat_copy_message": "Copy", + "agent_chat_edit_message": "Edit", + "agent_chat_save_and_send": "Save & Send", + "agent_chat_cancel_edit": "Cancel", + "agent_chat_message_queued": "Queued", + "agent_chat_cancel_message": "Cancel send", + "agent_permission_title": "The script is requesting to use Agent conversation", + "agent_permission_describe": "This script requests Agent conversation access, which will consume API tokens. Only grant access to trusted scripts.", + "agent_permission_content": "Agent Conversation", + "agent_opfs": "OPFS", + "agent_opfs_title": "OPFS File Browser", + "agent_opfs_empty": "Empty directory", + "agent_opfs_name": "Name", + "agent_opfs_size": "Size", + "agent_opfs_type": "Type", + "agent_opfs_modified": "Last Modified", + "agent_opfs_delete_confirm": "Are you sure to delete?", + "agent_opfs_delete_success": "Deleted", + "agent_opfs_file": "File", + "agent_opfs_directory": "Directory", + "agent_opfs_preview": "Preview", + "agent_opfs_root": "Root", + "agent_dom_permission_title": "The script is requesting DOM operation access", + "agent_dom_permission_describe": "This script requests the ability to read and manipulate web page DOM (click, fill forms, navigate, screenshot, etc.). Only grant access to trusted scripts.", + "agent_dom_permission_content": "Agent DOM Operations", + "agent_mcp_title": "MCP Servers", + "agent_mcp_add_server": "Add Server", + "agent_mcp_no_servers": "No MCP servers configured", + "agent_mcp_test_connection": "Test", + "agent_mcp_name_url_required": "Name and URL are required", + "agent_mcp_optional": "optional", + "agent_mcp_custom_headers": "Custom Headers", + "agent_mcp_enabled": "Enabled", + "agent_mcp_detail": "Details", + "agent_mcp_tools": "Tools", + "agent_mcp_resources": "Resources", + "agent_mcp_prompts": "Prompts", + "agent_mcp_no_tools": "No tools available", + "agent_mcp_no_resources": "No resources available", + "agent_mcp_no_prompts": "No prompts available", + "agent_mcp_loading": "Loading...", + "agent_mcp_parameters": "Parameters", + "agent_settings": "Settings", + "agent_settings_title": "Agent Settings", + "agent_model_settings": "Model Settings", + "agent_summary_model": "Summary Model", + "agent_summary_model_desc": "Model for summarization, falls back to default if not set", + "agent_summary_model_placeholder": "Use default model", + "agent_search_settings": "Search Settings", + "agent_search_engine": "Search Engine", + "agent_search_engine_baidu": "Baidu", + "agent_search_google_api_key": "Google API Key", + "agent_search_google_cse_id": "Custom Search Engine ID", + "agent_settings_saved": "Settings saved", + "agent_settings_save_failed": "Failed to save settings", + "agent_search_engine_tip_bing": "Default search engine with broad global coverage, no extra configuration needed.", + "agent_search_engine_tip_duckduckgo": "Privacy-focused search engine, no API key required.", + "agent_search_engine_tip_baidu": "Optimized for Chinese content, better results for Chinese queries.", + "agent_search_engine_tip_google": "High-quality search results, requires a Google API Key and Custom Search Engine ID." +} diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index eec855cac..7c5fc07e6 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -605,5 +605,150 @@ "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_copy": "Copy", + "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_max_tokens": "Max Output Tokens", + "agent_model_no_models": "No models configured", + "agent_model_vision_support": "Supports vision input", + "agent_model_image_output": "Supports image output", + "agent_model_capabilities": "Fähigkeiten", + "agent_model_supports_vision": "Bildeingabe", + "agent_model_supports_image_output": "Bildausgabe", + "agent_coming_soon": "Coming soon...", + "agent_skills_title": "Skills Management", + "agent_skills_add": "Add Skill", + "agent_skills_empty": "No skills installed", + "agent_skills_tools": "Tools", + "agent_skills_references": "References", + "agent_skills_detail": "Skill Details", + "agent_skills_edit_prompt": "Prompt", + "agent_skills_install": "Install Skill", + "agent_skills_install_url": "Import from URL", + "agent_skills_install_paste": "Paste SKILL.md", + "agent_skills_uninstall": "Uninstall", + "agent_skills_uninstall_confirm": "Are you sure to uninstall Skill \"{{name}}\"?", + "agent_skills_save_success": "Saved successfully", + "agent_skills_install_success": "Installed successfully", + "agent_skills_fetch_failed": "Fetch failed", + "agent_skills_add_script": "Add Script", + "agent_skills_add_reference": "Add Reference", + "agent_skills_install_zip": "Upload ZIP", + "agent_skills_install_zip_hint": "Click to select a .zip file", + "agent_skills_prompt": "Prompt", + "agent_skills_installed_at": "Installed at", + "agent_skills_refresh": "Refresh", + "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_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...", + "agent_chat_newline": "for new line", + "agent_chat_welcome_hint": "Ask me anything about your scripts", + "agent_chat_welcome_start": "Create a conversation to get started", + "agent_chat_tokens": "tokens", + "agent_chat_first_token": "TTFT", + "agent_chat_tools_count": "{{count}} tools", + "agent_chat_tools_enabled": "Tools enabled", + "agent_chat_tools_disabled": "Tools disabled", + "agent_chat_tools_enabled_tip": "Tools enabled — click to disable", + "agent_chat_tools_disabled_tip": "Tools disabled — click to enable", + "agent_chat_background_enabled_tip": "Hintergrundmodus AN — Konversation läuft nach dem Schließen der Seite weiter, klicken zum Deaktivieren", + "agent_chat_background_disabled_tip": "Hintergrundmodus AUS — Konversation stoppt beim Schließen der Seite, klicken zum Aktivieren", + "agent_chat_delete_round": "Delete", + "agent_chat_copy_message": "Copy", + "agent_chat_edit_message": "Bearbeiten", + "agent_chat_save_and_send": "Speichern & Senden", + "agent_chat_cancel_edit": "Abbrechen", + "agent_chat_message_queued": "In Warteschlange", + "agent_chat_cancel_message": "Senden abbrechen", + "agent_permission_title": "Das Skript fordert die Verwendung des Agent-Gesprächs an", + "agent_permission_describe": "Dieses Skript fordert Zugang zur Agent-Gesprächsfunktion an, was API-Token verbraucht. Erlauben Sie nur vertrauenswürdigen Skripten.", + "agent_permission_content": "Agent-Gespräch", + "agent_opfs": "OPFS", + "agent_opfs_title": "OPFS Dateibrowser", + "agent_opfs_empty": "Leeres Verzeichnis", + "agent_opfs_name": "Name", + "agent_opfs_size": "Größe", + "agent_opfs_type": "Typ", + "agent_opfs_modified": "Zuletzt geändert", + "agent_opfs_delete_confirm": "Sind Sie sicher, dass Sie löschen möchten?", + "agent_opfs_delete_success": "Gelöscht", + "agent_opfs_file": "Datei", + "agent_opfs_directory": "Verzeichnis", + "agent_opfs_preview": "Vorschau", + "agent_opfs_root": "Stammverzeichnis", + "agent_dom_permission_title": "Das Skript fordert DOM-Zugriff an", + "agent_dom_permission_describe": "Dieses Skript fordert die Fähigkeit an, das DOM der Webseite zu lesen und zu manipulieren (Klicken, Formulare ausfüllen, Navigation, Screenshot usw.). Erlauben Sie nur vertrauenswürdigen Skripten.", + "agent_dom_permission_content": "Agent DOM-Operationen", + "agent_mcp_title": "MCP-Server", + "agent_mcp_add_server": "Server hinzufügen", + "agent_mcp_no_servers": "Keine MCP-Server konfiguriert", + "agent_mcp_test_connection": "Testen", + "agent_mcp_name_url_required": "Name und URL sind erforderlich", + "agent_mcp_optional": "optional", + "agent_mcp_custom_headers": "Benutzerdefinierte Header", + "agent_mcp_enabled": "Aktiviert", + "agent_mcp_detail": "Details", + "agent_mcp_tools": "Werkzeuge", + "agent_mcp_resources": "Ressourcen", + "agent_mcp_prompts": "Prompts", + "agent_mcp_no_tools": "Keine Werkzeuge verfügbar", + "agent_mcp_no_resources": "Keine Ressourcen verfügbar", + "agent_mcp_no_prompts": "Keine Prompts verfügbar", + "agent_mcp_loading": "Laden...", + "agent_mcp_parameters": "Parameter", + "agent_settings": "Einstellungen", + "agent_settings_title": "Agent-Einstellungen", + "agent_model_settings": "Modelleinstellungen", + "agent_summary_model": "Zusammenfassungsmodell", + "agent_summary_model_desc": "Für Web-Zusammenfassungen. Wenn nicht eingestellt, wird das Standardmodell verwendet", + "agent_summary_model_placeholder": "Standardmodell verwenden", + "agent_search_settings": "Sucheinstellungen", + "agent_search_engine": "Suchmaschine", + "agent_search_engine_baidu": "Baidu", + "agent_search_google_api_key": "Google API Key", + "agent_search_google_cse_id": "Benutzerdefinierte Suchmaschinen-ID", + "agent_settings_saved": "Einstellungen gespeichert", + "agent_settings_save_failed": "Einstellungen konnten nicht gespeichert werden", + "agent_search_engine_tip_bing": "Standard-Suchmaschine mit breiter globaler Abdeckung, keine zusätzliche Konfiguration erforderlich.", + "agent_search_engine_tip_duckduckgo": "Datenschutzorientierte Suchmaschine, kein API-Schlüssel erforderlich.", + "agent_search_engine_tip_baidu": "Optimiert für chinesische Inhalte, bessere Ergebnisse für chinesische Suchanfragen.", + "agent_search_engine_tip_google": "Hochwertige Suchergebnisse, erfordert einen Google API Key und eine benutzerdefinierte Suchmaschinen-ID." +} diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index adfc25758..3f8b41c0b 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -605,5 +605,189 @@ "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_copy": "Copy", + "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_max_tokens": "Max Output Tokens", + "agent_model_context_window": "Context Window", + "agent_model_no_models": "No models configured", + "agent_model_vision_support": "Supports vision input", + "agent_model_image_output": "Supports image output", + "agent_model_capabilities": "Capabilities", + "agent_model_supports_vision": "Vision Input", + "agent_model_supports_image_output": "Image Output", + "agent_coming_soon": "Coming soon...", + "agent_skills_title": "Skills Management", + "agent_skills_add": "Add Skill", + "agent_skills_empty": "No skills installed", + "agent_skills_tools": "Tools", + "agent_skills_references": "References", + "agent_skills_detail": "Skill Details", + "agent_skills_edit_prompt": "Prompt", + "agent_skills_install": "Install Skill", + "agent_skills_install_url": "Import from URL", + "agent_skills_install_paste": "Paste SKILL.md", + "agent_skills_uninstall": "Uninstall", + "agent_skills_uninstall_confirm": "Are you sure to uninstall Skill \"{{name}}\"?", + "agent_skills_save_success": "Saved successfully", + "agent_skills_install_success": "Installed successfully", + "agent_skills_fetch_failed": "Fetch failed", + "agent_skills_add_script": "Add Script", + "agent_skills_add_reference": "Add Reference", + "agent_skills_install_zip": "Upload ZIP", + "agent_skills_install_zip_hint": "Click to select a .zip file", + "agent_skills_prompt": "Prompt", + "agent_skills_installed_at": "Installed at", + "agent_skills_refresh": "Refresh", + "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?", + "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...", + "agent_chat_retrying": "Retrying ({{attempt}}/{{max}})...", + "agent_chat_newline": "for new line", + "agent_chat_attach_image": "Attach image", + "agent_chat_attach_file": "Attach file", + "agent_chat_welcome_hint": "Ask me anything about your scripts", + "agent_chat_welcome_start": "Create a conversation to get started", + "agent_chat_tokens": "tokens", + "agent_chat_first_token": "TTFT", + "agent_chat_tools_count": "{{count}} tools", + "agent_chat_tools_enabled": "Tools enabled", + "agent_chat_tools_disabled": "Tools disabled", + "agent_chat_tools_enabled_tip": "Tools enabled — click to disable", + "agent_chat_tools_disabled_tip": "Tools disabled — click to enable", + "agent_chat_background_enabled_tip": "Background mode ON — conversation keeps running after closing the page, click to disable", + "agent_chat_background_disabled_tip": "Background mode OFF — conversation stops when the page is closed, click to enable", + "agent_chat_delete_round": "Delete", + "agent_chat_copy_message": "Copy", + "agent_chat_edit_message": "Edit", + "agent_chat_save_and_send": "Save & Send", + "agent_chat_cancel_edit": "Cancel", + "agent_chat_message_queued": "Queued", + "agent_chat_cancel_message": "Cancel send", + "agent_permission_title": "The script is requesting to use Agent conversation", + "agent_permission_describe": "This script requests Agent conversation access, which will consume API tokens. Only grant access to trusted scripts.", + "agent_permission_content": "Agent Conversation", + "agent_opfs": "OPFS", + "agent_opfs_title": "OPFS File Browser", + "agent_opfs_empty": "Empty directory", + "agent_opfs_name": "Name", + "agent_opfs_size": "Size", + "agent_opfs_type": "Type", + "agent_opfs_modified": "Last Modified", + "agent_opfs_delete_confirm": "Are you sure to delete?", + "agent_opfs_delete_success": "Deleted", + "agent_opfs_file": "File", + "agent_opfs_directory": "Directory", + "agent_opfs_preview": "Preview", + "agent_opfs_root": "Root", + "agent_dom_permission_title": "The script is requesting DOM operation access", + "agent_dom_permission_describe": "This script requests the ability to read and manipulate web page DOM (click, fill forms, navigate, screenshot, etc.). Only grant access to trusted scripts.", + "agent_dom_permission_content": "Agent DOM Operations", + "agent_mcp_title": "MCP Servers", + "agent_mcp_add_server": "Add Server", + "agent_mcp_no_servers": "No MCP servers configured", + "agent_mcp_test_connection": "Test", + "agent_mcp_name_url_required": "Name and URL are required", + "agent_mcp_optional": "optional", + "agent_mcp_custom_headers": "Custom Headers", + "agent_mcp_enabled": "Enabled", + "agent_mcp_detail": "Details", + "agent_mcp_tools": "Tools", + "agent_mcp_resources": "Resources", + "agent_mcp_prompts": "Prompts", + "agent_mcp_no_tools": "No tools available", + "agent_mcp_no_resources": "No resources available", + "agent_mcp_no_prompts": "No prompts available", + "agent_mcp_loading": "Loading...", + "agent_mcp_parameters": "Parameters", + "agent_tasks": "Tasks", + "agent_tasks_title": "Scheduled Tasks", + "agent_tasks_create": "Create Task", + "agent_tasks_edit": "Edit Task", + "agent_tasks_mode_internal": "Internal", + "agent_tasks_mode_event": "Event", + "agent_tasks_cron": "Cron Expression", + "agent_tasks_next_run": "Next Run", + "agent_tasks_last_status": "Last Status", + "agent_tasks_run_now": "Run Now", + "agent_tasks_history": "History", + "agent_tasks_prompt": "Prompt", + "agent_tasks_max_iterations": "Max Iterations", + "agent_tasks_notify": "Notify on Complete", + "agent_tasks_no_tasks": "No scheduled tasks", + "agent_tasks_delete_confirm": "Are you sure to delete this task?", + "agent_tasks_clear_runs": "Clear History", + "agent_tasks_clear_runs_confirm": "Are you sure to clear the run history?", + "agent_tasks_event_hint": "When triggered, the script that created this task will be notified", + "agent_tasks_name_cron_required": "Name and Cron expression are required", + "agent_tasks_model_select": "Select Model", + "agent_tasks_skills": "Skills", + "agent_tasks_skills_auto": "Auto load all", + "agent_tasks_conversation_id": "Continue conversation ID (optional)", + "agent_tasks_run_status_success": "Success", + "agent_tasks_run_status_error": "Error", + "agent_tasks_run_status_running": "Running", + "agent_tasks_run_duration": "Duration", + "agent_tasks_run_usage": "Usage", + "agent_tasks_run_conversation": "View Conversation", + "agent_tasks_run_time": "Time", + "agent_tasks_run_status": "Status", + "agent_tasks_never_run": "Never run", + "agent_settings": "Settings", + "agent_settings_title": "Agent Settings", + "agent_model_settings": "Model Settings", + "agent_summary_model": "Summary Model", + "agent_summary_model_desc": "Model for summarization, falls back to default if not set", + "agent_summary_model_placeholder": "Use default model", + "agent_search_settings": "Search Settings", + "agent_search_engine": "Search Engine", + "agent_search_engine_baidu": "Baidu", + "agent_search_google_api_key": "Google API Key", + "agent_search_google_cse_id": "Custom Search Engine ID", + "agent_settings_saved": "Settings saved", + "agent_settings_save_failed": "Failed to save settings", + "agent_search_engine_tip_bing": "Default search engine with broad global coverage, no extra configuration needed.", + "agent_search_engine_tip_duckduckgo": "Privacy-focused search engine, no API key required.", + "agent_search_engine_tip_baidu": "Optimized for Chinese content, better results for Chinese queries.", + "agent_search_engine_tip_google": "High-quality search results, requires a Google API Key and Custom Search Engine ID." +} diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index f02ab5c34..971563a08 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -605,5 +605,150 @@ "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_copy": "コピー", + "agent_model_delete": "削除", + "agent_model_set_default": "デフォルトに設定", + "agent_model_default_label": "デフォルト", + "agent_model_delete_confirm": "このモデル設定を削除してもよろしいですか?", + "agent_model_max_tokens": "最大出力トークン数", + "agent_model_no_models": "モデル設定がありません", + "agent_model_vision_support": "画像入力対応", + "agent_model_image_output": "画像出力対応", + "agent_model_capabilities": "モデル機能", + "agent_model_supports_vision": "画像入力", + "agent_model_supports_image_output": "画像出力", + "agent_coming_soon": "開発中...", + "agent_skills_title": "Skills 管理", + "agent_skills_add": "Skill を追加", + "agent_skills_empty": "インストール済みの Skill はありません", + "agent_skills_tools": "ツール", + "agent_skills_references": "参考資料", + "agent_skills_detail": "Skill 詳細", + "agent_skills_edit_prompt": "プロンプト", + "agent_skills_install": "Skill をインストール", + "agent_skills_install_url": "URL からインポート", + "agent_skills_install_paste": "SKILL.md を貼り付け", + "agent_skills_uninstall": "アンインストール", + "agent_skills_uninstall_confirm": "Skill「{{name}}」をアンインストールしますか?", + "agent_skills_save_success": "保存しました", + "agent_skills_install_success": "インストールしました", + "agent_skills_fetch_failed": "取得に失敗しました", + "agent_skills_add_script": "スクリプトを追加", + "agent_skills_add_reference": "参考資料を追加", + "agent_skills_install_zip": "ZIP アップロード", + "agent_skills_install_zip_hint": "クリックして .zip ファイルを選択", + "agent_skills_prompt": "プロンプト", + "agent_skills_installed_at": "インストール日時", + "agent_skills_refresh": "更新", + "agent_skills_refresh_success": "更新しました", + "agent_skills_tool_code": "ツールコード", + "agent_skills_click_to_view_code": "ツール名をクリックしてコードを表示", + "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": "生成中...", + "agent_chat_newline": "改行", + "agent_chat_welcome_hint": "スクリプトに関する質問は何でもどうぞ", + "agent_chat_welcome_start": "会話を作成して始めましょう", + "agent_chat_tokens": "トークン", + "agent_chat_first_token": "TTFT", + "agent_chat_tools_count": "{{count}} ツール呼び出し", + "agent_chat_tools_enabled": "ツール有効", + "agent_chat_tools_disabled": "ツール無効", + "agent_chat_tools_enabled_tip": "ツール有効 — クリックで無効化", + "agent_chat_tools_disabled_tip": "ツール無効 — クリックで有効化", + "agent_chat_background_enabled_tip": "バックグラウンドモード ON — ページを閉じても会話は継続します。クリックで無効化", + "agent_chat_background_disabled_tip": "バックグラウンドモード OFF — ページを閉じると会話は停止します。クリックで有効化", + "agent_chat_delete_round": "削除", + "agent_chat_copy_message": "コピー", + "agent_chat_edit_message": "編集", + "agent_chat_save_and_send": "保存して送信", + "agent_chat_cancel_edit": "キャンセル", + "agent_chat_message_queued": "キュー中", + "agent_chat_cancel_message": "送信をキャンセル", + "agent_permission_title": "スクリプトが Agent 会話の使用をリクエストしています", + "agent_permission_describe": "このスクリプトは Agent 会話機能の使用をリクエストしており、API トークンを消費します。信頼できるスクリプトにのみ許可してください。", + "agent_permission_content": "Agent 会話", + "agent_opfs": "OPFS", + "agent_opfs_title": "OPFS ファイルブラウザ", + "agent_opfs_empty": "空のディレクトリ", + "agent_opfs_name": "名前", + "agent_opfs_size": "サイズ", + "agent_opfs_type": "種類", + "agent_opfs_modified": "最終更新", + "agent_opfs_delete_confirm": "削除してよろしいですか?", + "agent_opfs_delete_success": "削除しました", + "agent_opfs_file": "ファイル", + "agent_opfs_directory": "ディレクトリ", + "agent_opfs_preview": "プレビュー", + "agent_opfs_root": "ルート", + "agent_dom_permission_title": "スクリプトが DOM 操作アクセスをリクエストしています", + "agent_dom_permission_describe": "このスクリプトはウェブページの DOM を読み取り操作する能力(クリック、フォーム入力、ナビゲーション、スクリーンショットなど)をリクエストしています。信頼できるスクリプトにのみ許可してください。", + "agent_dom_permission_content": "Agent DOM 操作", + "agent_mcp_title": "MCPサーバー", + "agent_mcp_add_server": "サーバーを追加", + "agent_mcp_no_servers": "MCPサーバーが設定されていません", + "agent_mcp_test_connection": "テスト", + "agent_mcp_name_url_required": "名前とURLは必須です", + "agent_mcp_optional": "任意", + "agent_mcp_custom_headers": "カスタムヘッダー", + "agent_mcp_enabled": "有効", + "agent_mcp_detail": "詳細", + "agent_mcp_tools": "ツール", + "agent_mcp_resources": "リソース", + "agent_mcp_prompts": "プロンプト", + "agent_mcp_no_tools": "利用可能なツールはありません", + "agent_mcp_no_resources": "利用可能なリソースはありません", + "agent_mcp_no_prompts": "利用可能なプロンプトはありません", + "agent_mcp_loading": "読み込み中...", + "agent_mcp_parameters": "パラメータ", + "agent_settings": "設定", + "agent_settings_title": "Agent 設定", + "agent_model_settings": "モデル設定", + "agent_summary_model": "要約モデル", + "agent_summary_model_desc": "ウェブ要約などに使用。未設定の場合はデフォルトモデルを使用", + "agent_summary_model_placeholder": "デフォルトモデルを使用", + "agent_search_settings": "検索設定", + "agent_search_engine": "検索エンジン", + "agent_search_engine_baidu": "Baidu", + "agent_search_google_api_key": "Google API Key", + "agent_search_google_cse_id": "カスタム検索エンジン ID", + "agent_settings_saved": "設定を保存しました", + "agent_settings_save_failed": "設定の保存に失敗しました", + "agent_search_engine_tip_bing": "デフォルトの検索エンジン、グローバルに広くカバー、追加設定不要。", + "agent_search_engine_tip_duckduckgo": "プライバシー重視の検索エンジン、API キー不要。", + "agent_search_engine_tip_baidu": "中国語コンテンツに最適化、中国語の検索結果がより良好。", + "agent_search_engine_tip_google": "高品質な検索結果、Google API Key とカスタム検索エンジン ID の設定が必要。" +} diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index 1607c3c57..e2c20fbbe 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -605,5 +605,150 @@ "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_copy": "Copy", + "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_max_tokens": "Max Output Tokens", + "agent_model_no_models": "No models configured", + "agent_model_vision_support": "Supports vision input", + "agent_model_image_output": "Supports image output", + "agent_model_capabilities": "Возможности", + "agent_model_supports_vision": "Ввод изображений", + "agent_model_supports_image_output": "Вывод изображений", + "agent_coming_soon": "Coming soon...", + "agent_skills_title": "Skills Management", + "agent_skills_add": "Add Skill", + "agent_skills_empty": "No skills installed", + "agent_skills_tools": "Tools", + "agent_skills_references": "References", + "agent_skills_detail": "Skill Details", + "agent_skills_edit_prompt": "Prompt", + "agent_skills_install": "Install Skill", + "agent_skills_install_url": "Import from URL", + "agent_skills_install_paste": "Paste SKILL.md", + "agent_skills_uninstall": "Uninstall", + "agent_skills_uninstall_confirm": "Are you sure to uninstall Skill \"{{name}}\"?", + "agent_skills_save_success": "Saved successfully", + "agent_skills_install_success": "Installed successfully", + "agent_skills_fetch_failed": "Fetch failed", + "agent_skills_add_script": "Add Script", + "agent_skills_add_reference": "Add Reference", + "agent_skills_install_zip": "Upload ZIP", + "agent_skills_install_zip_hint": "Click to select a .zip file", + "agent_skills_prompt": "Prompt", + "agent_skills_installed_at": "Installed at", + "agent_skills_refresh": "Refresh", + "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_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...", + "agent_chat_newline": "for new line", + "agent_chat_welcome_hint": "Ask me anything about your scripts", + "agent_chat_welcome_start": "Create a conversation to get started", + "agent_chat_tokens": "tokens", + "agent_chat_first_token": "TTFT", + "agent_chat_tools_count": "{{count}} tools", + "agent_chat_tools_enabled": "Tools enabled", + "agent_chat_tools_disabled": "Tools disabled", + "agent_chat_tools_enabled_tip": "Tools enabled — click to disable", + "agent_chat_tools_disabled_tip": "Tools disabled — click to enable", + "agent_chat_background_enabled_tip": "Фоновый режим ВКЛ — диалог продолжит работу после закрытия страницы, нажмите для отключения", + "agent_chat_background_disabled_tip": "Фоновый режим ВЫКЛ — диалог остановится при закрытии страницы, нажмите для включения", + "agent_chat_delete_round": "Delete", + "agent_chat_copy_message": "Copy", + "agent_chat_edit_message": "Редактировать", + "agent_chat_save_and_send": "Сохранить и отправить", + "agent_chat_cancel_edit": "Отмена", + "agent_chat_message_queued": "В очереди", + "agent_chat_cancel_message": "Отменить отправку", + "agent_permission_title": "Скрипт запрашивает использование Agent разговора", + "agent_permission_describe": "Этот скрипт запрашивает доступ к функции Agent разговора, что будет потреблять API токены. Разрешайте только доверенным скриптам.", + "agent_permission_content": "Agent разговор", + "agent_opfs": "OPFS", + "agent_opfs_title": "Файловый браузер OPFS", + "agent_opfs_empty": "Пустая директория", + "agent_opfs_name": "Имя", + "agent_opfs_size": "Размер", + "agent_opfs_type": "Тип", + "agent_opfs_modified": "Последнее изменение", + "agent_opfs_delete_confirm": "Вы уверены, что хотите удалить?", + "agent_opfs_delete_success": "Удалено", + "agent_opfs_file": "Файл", + "agent_opfs_directory": "Директория", + "agent_opfs_preview": "Предпросмотр", + "agent_opfs_root": "Корень", + "agent_dom_permission_title": "Скрипт запрашивает доступ к операциям DOM", + "agent_dom_permission_describe": "Этот скрипт запрашивает возможность читать и управлять DOM веб-страницы (клик, заполнение форм, навигация, скриншот и т.д.). Разрешайте только доверенным скриптам.", + "agent_dom_permission_content": "Agent DOM операции", + "agent_mcp_title": "MCP серверы", + "agent_mcp_add_server": "Добавить сервер", + "agent_mcp_no_servers": "MCP серверы не настроены", + "agent_mcp_test_connection": "Тест", + "agent_mcp_name_url_required": "Имя и URL обязательны", + "agent_mcp_optional": "необязательно", + "agent_mcp_custom_headers": "Пользовательские заголовки", + "agent_mcp_enabled": "Включено", + "agent_mcp_detail": "Подробности", + "agent_mcp_tools": "Инструменты", + "agent_mcp_resources": "Ресурсы", + "agent_mcp_prompts": "Промпты", + "agent_mcp_no_tools": "Нет доступных инструментов", + "agent_mcp_no_resources": "Нет доступных ресурсов", + "agent_mcp_no_prompts": "Нет доступных промптов", + "agent_mcp_loading": "Загрузка...", + "agent_mcp_parameters": "Параметры", + "agent_settings": "Настройки", + "agent_settings_title": "Настройки Agent", + "agent_model_settings": "Настройки модели", + "agent_summary_model": "Модель для резюме", + "agent_summary_model_desc": "Используется для суммаризации веб-страниц. При отсутствии используется модель по умолчанию", + "agent_summary_model_placeholder": "Использовать модель по умолчанию", + "agent_search_settings": "Настройки поиска", + "agent_search_engine": "Поисковая система", + "agent_search_engine_baidu": "Baidu", + "agent_search_google_api_key": "Google API Key", + "agent_search_google_cse_id": "ID пользовательской поисковой системы", + "agent_settings_saved": "Настройки сохранены", + "agent_settings_save_failed": "Не удалось сохранить настройки", + "agent_search_engine_tip_bing": "Поисковая система по умолчанию с широким глобальным охватом, дополнительная настройка не требуется.", + "agent_search_engine_tip_duckduckgo": "Поисковая система с акцентом на конфиденциальность, API-ключ не требуется.", + "agent_search_engine_tip_baidu": "Оптимизирован для китайского контента, лучшие результаты для запросов на китайском языке.", + "agent_search_engine_tip_google": "Высококачественные результаты поиска, требуется Google API Key и ID пользовательской поисковой системы." +} diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index bca7992c6..740a78bfc 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -605,5 +605,150 @@ "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_copy": "Copy", + "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_max_tokens": "Max Output Tokens", + "agent_model_no_models": "No models configured", + "agent_model_vision_support": "Supports vision input", + "agent_model_image_output": "Supports image output", + "agent_model_capabilities": "Khả năng", + "agent_model_supports_vision": "Nhập hình ảnh", + "agent_model_supports_image_output": "Xuất hình ảnh", + "agent_coming_soon": "Coming soon...", + "agent_skills_title": "Skills Management", + "agent_skills_add": "Add Skill", + "agent_skills_empty": "No skills installed", + "agent_skills_tools": "Tools", + "agent_skills_references": "References", + "agent_skills_detail": "Skill Details", + "agent_skills_edit_prompt": "Prompt", + "agent_skills_install": "Install Skill", + "agent_skills_install_url": "Import from URL", + "agent_skills_install_paste": "Paste SKILL.md", + "agent_skills_uninstall": "Uninstall", + "agent_skills_uninstall_confirm": "Are you sure to uninstall Skill \"{{name}}\"?", + "agent_skills_save_success": "Saved successfully", + "agent_skills_install_success": "Installed successfully", + "agent_skills_fetch_failed": "Fetch failed", + "agent_skills_add_script": "Add Script", + "agent_skills_add_reference": "Add Reference", + "agent_skills_install_zip": "Upload ZIP", + "agent_skills_install_zip_hint": "Click to select a .zip file", + "agent_skills_prompt": "Prompt", + "agent_skills_installed_at": "Installed at", + "agent_skills_refresh": "Refresh", + "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_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...", + "agent_chat_newline": "for new line", + "agent_chat_welcome_hint": "Ask me anything about your scripts", + "agent_chat_welcome_start": "Create a conversation to get started", + "agent_chat_tokens": "tokens", + "agent_chat_first_token": "TTFT", + "agent_chat_tools_count": "{{count}} tools", + "agent_chat_tools_enabled": "Tools enabled", + "agent_chat_tools_disabled": "Tools disabled", + "agent_chat_tools_enabled_tip": "Tools enabled — click to disable", + "agent_chat_tools_disabled_tip": "Tools disabled — click to enable", + "agent_chat_background_enabled_tip": "Chế độ nền BẬT — cuộc hội thoại tiếp tục chạy sau khi đóng trang, nhấn để tắt", + "agent_chat_background_disabled_tip": "Chế độ nền TẮT — cuộc hội thoại dừng khi đóng trang, nhấn để bật", + "agent_chat_delete_round": "Delete", + "agent_chat_copy_message": "Copy", + "agent_chat_edit_message": "Chỉnh sửa", + "agent_chat_save_and_send": "Lưu & Gửi", + "agent_chat_cancel_edit": "Hủy", + "agent_chat_message_queued": "Đang chờ", + "agent_chat_cancel_message": "Hủy gửi", + "agent_permission_title": "Script yêu cầu sử dụng cuộc trò chuyện Agent", + "agent_permission_describe": "Script này yêu cầu quyền truy cập chức năng cuộc trò chuyện Agent, sẽ tiêu thụ API token. Chỉ cấp quyền cho các script đáng tin cậy.", + "agent_permission_content": "Cuộc trò chuyện Agent", + "agent_opfs": "OPFS", + "agent_opfs_title": "Trình duyệt tệp OPFS", + "agent_opfs_empty": "Thư mục trống", + "agent_opfs_name": "Tên", + "agent_opfs_size": "Kích thước", + "agent_opfs_type": "Loại", + "agent_opfs_modified": "Sửa đổi lần cuối", + "agent_opfs_delete_confirm": "Bạn có chắc chắn muốn xóa không?", + "agent_opfs_delete_success": "Đã xóa", + "agent_opfs_file": "Tệp", + "agent_opfs_directory": "Thư mục", + "agent_opfs_preview": "Xem trước", + "agent_opfs_root": "Gốc", + "agent_dom_permission_title": "Script yêu cầu quyền thao tác DOM", + "agent_dom_permission_describe": "Script này yêu cầu khả năng đọc và thao tác DOM trang web (nhấp chuột, điền biểu mẫu, điều hướng, chụp ảnh màn hình, v.v.). Chỉ cấp quyền cho các script đáng tin cậy.", + "agent_dom_permission_content": "Agent DOM thao tác", + "agent_mcp_title": "Máy chủ MCP", + "agent_mcp_add_server": "Thêm máy chủ", + "agent_mcp_no_servers": "Chưa cấu hình máy chủ MCP", + "agent_mcp_test_connection": "Kiểm tra", + "agent_mcp_name_url_required": "Tên và URL là bắt buộc", + "agent_mcp_optional": "tùy chọn", + "agent_mcp_custom_headers": "Header tùy chỉnh", + "agent_mcp_enabled": "Đã bật", + "agent_mcp_detail": "Chi tiết", + "agent_mcp_tools": "Công cụ", + "agent_mcp_resources": "Tài nguyên", + "agent_mcp_prompts": "Lời nhắc", + "agent_mcp_no_tools": "Không có công cụ nào", + "agent_mcp_no_resources": "Không có tài nguyên nào", + "agent_mcp_no_prompts": "Không có lời nhắc nào", + "agent_mcp_loading": "Đang tải...", + "agent_mcp_parameters": "Tham số", + "agent_settings": "Cài đặt", + "agent_settings_title": "Cài đặt Agent", + "agent_model_settings": "Cài đặt mô hình", + "agent_summary_model": "Mô hình tóm tắt", + "agent_summary_model_desc": "Dùng cho tóm tắt trang web. Nếu chưa đặt sẽ dùng mô hình mặc định", + "agent_summary_model_placeholder": "Dùng mô hình mặc định", + "agent_search_settings": "Cài đặt tìm kiếm", + "agent_search_engine": "Công cụ tìm kiếm", + "agent_search_engine_baidu": "Baidu", + "agent_search_google_api_key": "Google API Key", + "agent_search_google_cse_id": "ID công cụ tìm kiếm tùy chỉnh", + "agent_settings_saved": "Đã lưu cài đặt", + "agent_settings_save_failed": "Lưu cài đặt thất bại", + "agent_search_engine_tip_bing": "Công cụ tìm kiếm mặc định, phạm vi toàn cầu rộng, không cần cấu hình thêm.", + "agent_search_engine_tip_duckduckgo": "Công cụ tìm kiếm chú trọng quyền riêng tư, không cần API Key.", + "agent_search_engine_tip_baidu": "Tối ưu cho nội dung tiếng Trung, kết quả tốt hơn cho truy vấn tiếng Trung.", + "agent_search_engine_tip_google": "Kết quả tìm kiếm chất lượng cao, cần cấu hình Google API Key và ID công cụ tìm kiếm tùy chỉnh." +} diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index be595b6cd..540cec729 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -605,5 +605,189 @@ "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_copy": "复制", + "agent_model_delete": "删除", + "agent_model_set_default": "设为默认", + "agent_model_default_label": "默认", + "agent_model_delete_confirm": "确定要删除此模型配置吗?", + "agent_model_max_tokens": "最大输出 Token 数", + "agent_model_context_window": "上下文窗口大小", + "agent_model_no_models": "暂无模型配置", + "agent_model_vision_support": "支持图片输入", + "agent_model_image_output": "支持图片输出", + "agent_model_capabilities": "模型能力", + "agent_model_supports_vision": "视觉输入", + "agent_model_supports_image_output": "图片输出", + "agent_coming_soon": "开发中...", + "agent_skills_title": "Skills 管理", + "agent_skills_add": "添加 Skill", + "agent_skills_empty": "暂无已安装的 Skill", + "agent_skills_tools": "工具", + "agent_skills_references": "参考资料", + "agent_skills_detail": "Skill 详情", + "agent_skills_edit_prompt": "提示词", + "agent_skills_install": "安装 Skill", + "agent_skills_install_url": "从 URL 导入", + "agent_skills_install_paste": "粘贴 SKILL.md", + "agent_skills_uninstall": "卸载", + "agent_skills_uninstall_confirm": "确定要卸载 Skill「{{name}}」?", + "agent_skills_save_success": "保存成功", + "agent_skills_install_success": "安装成功", + "agent_skills_fetch_failed": "获取失败", + "agent_skills_add_script": "添加脚本", + "agent_skills_add_reference": "添加参考资料", + "agent_skills_install_zip": "上传 ZIP", + "agent_skills_install_zip_hint": "点击选择 .zip 文件", + "agent_skills_prompt": "提示词", + "agent_skills_installed_at": "安装时间", + "agent_skills_refresh": "刷新", + "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": "确定要删除此会话吗?", + "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": "生成中...", + "agent_chat_retrying": "正在重试 ({{attempt}}/{{max}})...", + "agent_chat_newline": "换行", + "agent_chat_attach_image": "添加图片", + "agent_chat_attach_file": "添加文件", + "agent_chat_welcome_hint": "有关脚本的任何问题,尽管问我", + "agent_chat_welcome_start": "创建一个对话开始吧", + "agent_chat_tokens": "令牌", + "agent_chat_first_token": "TTFT", + "agent_chat_tools_count": "{{count}} 次工具调用", + "agent_chat_tools_enabled": "工具已启用", + "agent_chat_tools_disabled": "工具已禁用", + "agent_chat_tools_enabled_tip": "工具已启用 — 点击禁用", + "agent_chat_tools_disabled_tip": "工具已禁用 — 点击启用", + "agent_chat_background_enabled_tip": "后台运行已开启 — 关闭页面后对话将继续执行,点击关闭", + "agent_chat_background_disabled_tip": "后台运行已关闭 — 关闭页面后对话将停止,点击开启", + "agent_chat_delete_round": "删除", + "agent_chat_copy_message": "复制", + "agent_chat_edit_message": "编辑", + "agent_chat_save_and_send": "保存并发送", + "agent_chat_cancel_edit": "取消", + "agent_chat_message_queued": "排队中", + "agent_chat_cancel_message": "取消发送", + "agent_permission_title": "脚本请求使用 Agent 对话", + "agent_permission_describe": "此脚本请求使用 Agent 对话功能,将消耗 API Token。请仅对可信脚本授权。", + "agent_permission_content": "Agent 对话", + "agent_opfs": "OPFS", + "agent_opfs_title": "OPFS 文件浏览器", + "agent_opfs_empty": "空目录", + "agent_opfs_name": "名称", + "agent_opfs_size": "大小", + "agent_opfs_type": "类型", + "agent_opfs_modified": "最后修改", + "agent_opfs_delete_confirm": "确定要删除吗?", + "agent_opfs_delete_success": "已删除", + "agent_opfs_file": "文件", + "agent_opfs_directory": "目录", + "agent_opfs_preview": "预览", + "agent_opfs_root": "根目录", + "agent_dom_permission_title": "脚本请求 DOM 操作权限", + "agent_dom_permission_describe": "此脚本请求读取和操作网页 DOM 的能力(点击、填写表单、导航、截图等)。请仅对可信脚本授权。", + "agent_dom_permission_content": "Agent DOM 操作", + "agent_mcp_title": "MCP 服务器", + "agent_mcp_add_server": "添加服务器", + "agent_mcp_no_servers": "未配置 MCP 服务器", + "agent_mcp_test_connection": "测试", + "agent_mcp_name_url_required": "名称和 URL 不能为空", + "agent_mcp_optional": "可选", + "agent_mcp_custom_headers": "自定义请求头", + "agent_mcp_enabled": "启用", + "agent_mcp_detail": "详情", + "agent_mcp_tools": "工具", + "agent_mcp_resources": "资源", + "agent_mcp_prompts": "提示词", + "agent_mcp_no_tools": "暂无可用工具", + "agent_mcp_no_resources": "暂无可用资源", + "agent_mcp_no_prompts": "暂无可用提示词", + "agent_mcp_loading": "加载中...", + "agent_mcp_parameters": "参数", + "agent_tasks": "定时任务", + "agent_tasks_title": "定时任务管理", + "agent_tasks_create": "创建任务", + "agent_tasks_edit": "编辑任务", + "agent_tasks_mode_internal": "内部执行", + "agent_tasks_mode_event": "事件驱动", + "agent_tasks_cron": "Cron 表达式", + "agent_tasks_next_run": "下次运行", + "agent_tasks_last_status": "上次状态", + "agent_tasks_run_now": "立即运行", + "agent_tasks_history": "运行历史", + "agent_tasks_prompt": "提示词", + "agent_tasks_max_iterations": "最大迭代次数", + "agent_tasks_notify": "完成通知", + "agent_tasks_no_tasks": "暂无定时任务", + "agent_tasks_delete_confirm": "确定要删除此定时任务吗?", + "agent_tasks_clear_runs": "清除历史", + "agent_tasks_clear_runs_confirm": "确定要清除此任务的运行历史吗?", + "agent_tasks_event_hint": "任务触发时将通知创建此任务的脚本", + "agent_tasks_name_cron_required": "名称和 Cron 表达式不能为空", + "agent_tasks_model_select": "选择模型", + "agent_tasks_skills": "Skills", + "agent_tasks_skills_auto": "自动加载全部", + "agent_tasks_conversation_id": "续接对话 ID(可选)", + "agent_tasks_run_status_success": "成功", + "agent_tasks_run_status_error": "失败", + "agent_tasks_run_status_running": "运行中", + "agent_tasks_run_duration": "耗时", + "agent_tasks_run_usage": "用量", + "agent_tasks_run_conversation": "查看对话", + "agent_tasks_run_time": "时间", + "agent_tasks_run_status": "状态", + "agent_tasks_never_run": "未运行", + "agent_settings": "设置", + "agent_settings_title": "Agent 设置", + "agent_model_settings": "模型设置", + "agent_summary_model": "摘要模型", + "agent_summary_model_desc": "用于网页摘要等场景,未设置时使用默认模型", + "agent_summary_model_placeholder": "使用默认模型", + "agent_search_settings": "搜索设置", + "agent_search_engine": "搜索引擎", + "agent_search_engine_baidu": "百度", + "agent_search_google_api_key": "Google API Key", + "agent_search_google_cse_id": "自定义搜索引擎 ID", + "agent_settings_saved": "设置已保存", + "agent_settings_save_failed": "保存设置失败", + "agent_search_engine_tip_bing": "默认搜索引擎,全球覆盖广泛,无需额外配置。", + "agent_search_engine_tip_duckduckgo": "注重隐私保护的搜索引擎,无需 API Key。", + "agent_search_engine_tip_baidu": "针对中文内容优化,中文搜索效果更好。", + "agent_search_engine_tip_google": "搜索质量更高,需要配置 Google API Key 和自定义搜索引擎 ID。" +} diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 9b4a48477..949665f0e 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -605,5 +605,150 @@ "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_copy": "複製", + "agent_model_delete": "刪除", + "agent_model_set_default": "設為預設", + "agent_model_default_label": "預設", + "agent_model_delete_confirm": "確定要刪除此模型配置嗎?", + "agent_model_max_tokens": "最大輸出 Token 數", + "agent_model_no_models": "暫無模型配置", + "agent_model_vision_support": "支援圖片輸入", + "agent_model_image_output": "支援圖片輸出", + "agent_model_capabilities": "模型能力", + "agent_model_supports_vision": "視覺輸入", + "agent_model_supports_image_output": "圖片輸出", + "agent_coming_soon": "開發中...", + "agent_skills_title": "Skills 管理", + "agent_skills_add": "新增 Skill", + "agent_skills_empty": "尚無已安裝的 Skill", + "agent_skills_tools": "工具", + "agent_skills_references": "參考資料", + "agent_skills_detail": "Skill 詳情", + "agent_skills_edit_prompt": "提示詞", + "agent_skills_install": "安裝 Skill", + "agent_skills_install_url": "從 URL 匯入", + "agent_skills_install_paste": "貼上 SKILL.md", + "agent_skills_uninstall": "解除安裝", + "agent_skills_uninstall_confirm": "確定要解除安裝 Skill「{{name}}」?", + "agent_skills_save_success": "儲存成功", + "agent_skills_install_success": "安裝成功", + "agent_skills_fetch_failed": "取得失敗", + "agent_skills_add_script": "新增腳本", + "agent_skills_add_reference": "新增參考資料", + "agent_skills_install_zip": "上傳 ZIP", + "agent_skills_install_zip_hint": "點擊選擇 .zip 檔案", + "agent_skills_prompt": "提示詞", + "agent_skills_installed_at": "安裝時間", + "agent_skills_refresh": "重新整理", + "agent_skills_refresh_success": "重新整理成功", + "agent_skills_tool_code": "工具程式碼", + "agent_skills_click_to_view_code": "點擊工具名稱查看程式碼", + "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": "生成中...", + "agent_chat_newline": "換行", + "agent_chat_welcome_hint": "有關腳本的任何問題,儘管問我", + "agent_chat_welcome_start": "建立一個對話開始吧", + "agent_chat_tokens": "令牌", + "agent_chat_first_token": "TTFT", + "agent_chat_tools_count": "{{count}} 次工具調用", + "agent_chat_tools_enabled": "工具已啟用", + "agent_chat_tools_disabled": "工具已停用", + "agent_chat_tools_enabled_tip": "工具已啟用 — 點擊停用", + "agent_chat_tools_disabled_tip": "工具已停用 — 點擊啟用", + "agent_chat_background_enabled_tip": "背景運行已開啟 — 關閉頁面後對話將繼續執行,點擊關閉", + "agent_chat_background_disabled_tip": "背景運行已關閉 — 關閉頁面後對話將停止,點擊開啟", + "agent_chat_delete_round": "刪除", + "agent_chat_copy_message": "複製", + "agent_chat_edit_message": "編輯", + "agent_chat_save_and_send": "儲存並傳送", + "agent_chat_cancel_edit": "取消", + "agent_chat_message_queued": "排隊中", + "agent_chat_cancel_message": "取消傳送", + "agent_permission_title": "腳本請求使用 Agent 對話", + "agent_permission_describe": "此腳本請求使用 Agent 對話功能,將消耗 API Token。請僅對可信腳本授權。", + "agent_permission_content": "Agent 對話", + "agent_opfs": "OPFS", + "agent_opfs_title": "OPFS 檔案瀏覽器", + "agent_opfs_empty": "空目錄", + "agent_opfs_name": "名稱", + "agent_opfs_size": "大小", + "agent_opfs_type": "類型", + "agent_opfs_modified": "最後修改", + "agent_opfs_delete_confirm": "確定要刪除嗎?", + "agent_opfs_delete_success": "已刪除", + "agent_opfs_file": "檔案", + "agent_opfs_directory": "目錄", + "agent_opfs_preview": "預覽", + "agent_opfs_root": "根目錄", + "agent_dom_permission_title": "腳本請求 DOM 操作權限", + "agent_dom_permission_describe": "此腳本請求讀取和操作網頁 DOM 的能力(點擊、填寫表單、導航、截圖等)。請僅對可信腳本授權。", + "agent_dom_permission_content": "Agent DOM 操作", + "agent_mcp_title": "MCP 伺服器", + "agent_mcp_add_server": "新增伺服器", + "agent_mcp_no_servers": "未配置 MCP 伺服器", + "agent_mcp_test_connection": "測試", + "agent_mcp_name_url_required": "名稱和 URL 不能為空", + "agent_mcp_optional": "可選", + "agent_mcp_custom_headers": "自訂請求標頭", + "agent_mcp_enabled": "啟用", + "agent_mcp_detail": "詳情", + "agent_mcp_tools": "工具", + "agent_mcp_resources": "資源", + "agent_mcp_prompts": "提示詞", + "agent_mcp_no_tools": "暫無可用工具", + "agent_mcp_no_resources": "暫無可用資源", + "agent_mcp_no_prompts": "暫無可用提示詞", + "agent_mcp_loading": "載入中...", + "agent_mcp_parameters": "參數", + "agent_settings": "設定", + "agent_settings_title": "Agent 設定", + "agent_model_settings": "模型設定", + "agent_summary_model": "摘要模型", + "agent_summary_model_desc": "用於網頁摘要等場景,未設定時使用預設模型", + "agent_summary_model_placeholder": "使用預設模型", + "agent_search_settings": "搜尋設定", + "agent_search_engine": "搜尋引擎", + "agent_search_engine_baidu": "百度", + "agent_search_google_api_key": "Google API Key", + "agent_search_google_cse_id": "自訂搜尋引擎 ID", + "agent_settings_saved": "設定已儲存", + "agent_settings_save_failed": "儲存設定失敗", + "agent_search_engine_tip_bing": "預設搜尋引擎,全球覆蓋廣泛,無需額外設定。", + "agent_search_engine_tip_duckduckgo": "注重隱私保護的搜尋引擎,無需 API Key。", + "agent_search_engine_tip_baidu": "針對中文內容最佳化,中文搜尋效果更好。", + "agent_search_engine_tip_google": "搜尋品質更高,需要設定 Google API Key 和自訂搜尋引擎 ID。" +} diff --git a/src/manifest.json b/src/manifest.json index d55d8d80b..cda26358a 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -41,7 +41,8 @@ "notifications", "clipboardWrite", "unlimitedStorage", - "declarativeNetRequest" + "declarativeNetRequest", + "debugger" ], "optional_permissions": [ "background", diff --git a/src/pages/components/layout/MainLayout.tsx b/src/pages/components/layout/MainLayout.tsx index b254dca97..b2117ab07 100644 --- a/src/pages/components/layout/MainLayout.tsx +++ b/src/pages/components/layout/MainLayout.tsx @@ -27,13 +27,14 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAppContext } from "@App/pages/store/AppContext"; import { RiFileCodeLine, RiImportLine, RiPlayListAddLine, RiTerminalBoxLine, RiTimerLine } from "react-icons/ri"; -import { scriptClient } from "@App/pages/store/features/script"; +import { scriptClient, agentClient } from "@App/pages/store/features/script"; import { useDropzone, type FileWithPath } from "react-dropzone"; import { systemConfig } from "@App/pages/store/global"; import i18n, { matchLanguage } from "@App/locales/locales"; import "./index.css"; import { arcoLocale } from "@App/locales/arco"; -import { prepareScriptByCode } from "@App/pkg/utils/script"; +import { prepareScriptByCode, parseMetadata } from "@App/pkg/utils/script"; +import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; import { saveHandle } from "@App/pkg/utils/filehandle-db"; import { makeBlobURL } from "@App/pkg/utils/utils"; @@ -197,6 +198,20 @@ const MainLayout: React.FC<{ if (stat) showImportResult(stat); }; + // 处理 ZIP 文件的 Skill 安装 + const handleZipSkillInstall = async (file: File) => { + const buffer = await file.arrayBuffer(); + // ArrayBuffer → base64(chrome.runtime 消息不支持直接传 ArrayBuffer) + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64 = btoa(binary); + const uuid = await agentClient.prepareSkillInstall(base64); + window.open(`/src/install.html?skill=${uuid}`, "_blank"); + }; + const onDrop = (acceptedFiles: FileWithPath[]) => { // 本地的文件在当前页面处理,打开安装页面,将FileSystemFileHandle传递过去 // 实现本地文件的监听 @@ -204,6 +219,12 @@ const MainLayout: React.FC<{ Promise.all( acceptedFiles.map(async (aFile) => { try { + // ZIP 文件走 Skill 安装流程 + if (aFile.name.endsWith(".zip")) { + await handleZipSkillInstall(aFile); + stat.success++; + return; + } // 解析看看是不是一个标准的script文件 // 如果是,则打开安装页面 let fileHandle = aFile.handle; @@ -232,9 +253,17 @@ const MainLayout: React.FC<{ if (!file.name || !file.size) { throw new Error("No Read Access Right for File"); } - // 先检查内容,后弹出安装页面 + // 先检查内容,后弹出安装页面(支持 UserScript 和 SkillScript) const checkOk = await Promise.allSettled([ - file.text().then((code) => prepareScriptByCode(code, `file:///*resp-check*/${file.name}`)), + file.text().then((code) => { + // 先尝试 UserScript 解析 + const metadata = parseMetadata(code); + if (metadata) return prepareScriptByCode(code, `file:///*resp-check*/${file.name}`); + // 再尝试 SkillScript 解析 + const skillScriptMeta = parseSkillScriptMetadata(code); + if (skillScriptMeta) return { script: {} as any }; + throw new Error("not a valid UserScript or SkillScript"); + }), simpleDigestMessage(`f=${file.name}\ns=${file.size},m=${file.lastModified}`), ]); if (checkOk[0].status === "rejected" || !checkOk[0].value || checkOk[1].status === "rejected") { @@ -259,7 +288,7 @@ const MainLayout: React.FC<{ }; const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept: { "text/javascript": [".js"] }, + accept: { "text/javascript": [".js"], "application/zip": [".zip"] }, onDrop, noClick: true, noKeyboard: true, @@ -315,7 +344,7 @@ const MainLayout: React.FC<{ }} > {contextHolder} - + diff --git a/src/pages/components/layout/Sider.tsx b/src/pages/components/layout/Sider.tsx index 4770b8cc9..356286adf 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,43 @@ 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_skills")} + + + {t("agent_mcp")} + + + {t("agent_tasks")} + + + {t("agent_opfs")} + + + {t("agent_settings")} + + {t("logs")} @@ -190,7 +237,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", }} @@ -205,6 +252,7 @@ const Sider: React.FC = () => { } /> } /> } /> + } /> } />
    diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index cc5453e59..a01a396e0 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -1,745 +1,46 @@ -import { - Button, - Dropdown, - Message, - Menu, - Modal, - Space, - Switch, - Tag, - Tooltip, - Typography, - Popover, -} from "@arco-design/web-react"; -import { IconDown } from "@arco-design/web-react/icon"; -import { uuidv4 } from "@App/pkg/utils/uuid"; -import CodeEditor from "../components/CodeEditor"; -import { useEffect, useMemo, useState } from "react"; -import type { SCMetadata, Script } from "@App/app/repo/scripts"; -import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE } from "@App/app/repo/scripts"; -import type { Subscribe } from "@App/app/repo/subscribe"; -import { i18nDescription, i18nName } from "@App/locales/locales"; +import { Space, Typography } from "@arco-design/web-react"; import { useTranslation } from "react-i18next"; -import { createScriptInfo, type ScriptInfo } from "@App/pkg/utils/scriptInstall"; -import { parseMetadata, prepareScriptByCode, prepareSubscribeByCode } from "@App/pkg/utils/script"; -import { nextTimeDisplay } from "@App/pkg/utils/cron"; -import { scriptClient, subscribeClient } from "../store/features/script"; -import { type FTInfo, startFileTrack, unmountFileTrack } from "@App/pkg/utils/file-tracker"; -import { cleanupOldHandles, loadHandle, saveHandle } from "@App/pkg/utils/filehandle-db"; -import { dayFormat } from "@App/pkg/utils/day_format"; -import { intervalExecution, timeoutExecution } from "@App/pkg/utils/timer"; -import { useSearchParams } from "react-router-dom"; -import { CACHE_KEY_SCRIPT_INFO } from "@App/app/cache_key"; -import { cacheInstance } from "@App/app/cache"; -import { formatBytes, isPermissionOk } from "@App/pkg/utils/utils"; -import { ScriptIcons } from "../options/routes/utils"; -import { bytesDecode, detectEncoding } from "@App/pkg/utils/encoding"; -import { prettyUrl } from "@App/pkg/utils/url-utils"; - -const backgroundPromptShownKey = "background_prompt_shown"; - -type ScriptOrSubscribe = Script | Subscribe; - -// Types -interface PermissionItem { - label: string; - color?: string; - value: string[]; -} - -type Permission = PermissionItem[]; - -const closeWindow = (doBackwards: boolean) => { - if (doBackwards) { - history.go(-1); - } else { - window.close(); - } -}; - -const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any }) => { - let origin; - try { - origin = new URL(url).origin; - } catch { - throw new Error(`Invalid url: ${url}`); - } - const response = await fetch(url, { - headers: { - "Cache-Control": "no-cache", - // 参考:加权 Accept-Encoding 值说明 - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding#weighted_accept-encoding_values - "Accept-Encoding": "br;q=1.0, gzip;q=0.8, *;q=0.1", - Origin: origin, - }, - referrer: origin + "/", - }); - - if (!response.ok) { - throw new Error(`Fetch failed with status ${response.status}`); - } - - if (!response.body || !response.headers) { - throw new Error("No response body or headers"); - } - const reader = response.body.getReader(); - - // 读取数据 - let receivedLength = 0; // 当前已接收的长度 - const chunks = []; // 已接收的二进制分片数组(用于组装正文) - while (true) { - const { done, value } = await reader.read(); - - if (done) { - break; - } - - chunks.push(value); - receivedLength += value.length; - onProgress?.({ receivedLength }); - } - - // 合并分片(chunks) - const chunksAll = new Uint8Array(receivedLength); - let position = 0; - for (const chunk of chunks) { - chunksAll.set(chunk, position); - position += chunk.length; - } - - // 检测编码:优先使用 Content-Type,回退到 chardet(仅检测前16KB) - const contentType = response.headers.get("content-type"); - const encode = detectEncoding(chunksAll, contentType); - - // 使用检测到的 charset 解码 - let code; - try { - code = bytesDecode(encode, chunksAll); - } catch (e: any) { - console.warn(`Failed to decode response with charset ${encode}: ${e.message}`); - // 回退到 UTF-8 - code = new TextDecoder("utf-8").decode(chunksAll); - } - - const metadata = parseMetadata(code); - if (!metadata) { - throw new Error("parse script info failed"); - } - - return { code, metadata }; -}; - -const cleanupStaleInstallInfo = (uuid: string) => { - // 页面打开时不清除当前uuid,每30秒更新一次记录 - const f = () => { - cacheInstance.tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { - val = val || {}; - val[uuid] = Date.now(); - tx.set(val); - }); - }; - f(); - setInterval(f, 30_000); - - // 页面打开后清除旧记录 - const delay = Math.floor(5000 * Math.random()) + 10000; // 使用随机时间避免浏览器重启时大量Tabs同时执行清除 - timeoutExecution( - `${cIdKey}cleanupStaleInstallInfo`, - () => { - cacheInstance - .tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { - const now = Date.now(); - const keeps = new Set(); - const out: Record = {}; - for (const [k, ts] of Object.entries(val ?? {})) { - if (ts > 0 && now - ts < 60_000) { - keeps.add(`${CACHE_KEY_SCRIPT_INFO}${k}`); - out[k] = ts; - } - } - tx.set(out); - return keeps; - }) - .then(async (keeps) => { - const list = await cacheInstance.list(); - const filtered = list.filter((key) => key.startsWith(CACHE_KEY_SCRIPT_INFO) && !keeps.has(key)); - if (filtered.length) { - // 清理缓存 - cacheInstance.dels(filtered); - } - }); - }, - delay - ); -}; - -const cIdKey = `(cid_${Math.random()})`; +import { useInstallData } from "./hooks"; +import SkillInstallView from "./components/SkillInstallView"; +import ScriptInstallView from "./components/ScriptInstallView"; function App() { - const [enable, setEnable] = useState(false); - const [btnText, setBtnText] = useState(""); - const [scriptCode, setScriptCode] = useState(""); - const [scriptInfo, setScriptInfo] = useState(); - const [upsertScript, setUpsertScript] = useState(undefined); - const [diffCode, setDiffCode] = useState(); - const [oldScriptVersion, setOldScriptVersion] = useState(null); - const [isUpdate, setIsUpdate] = useState(false); - const [localFileHandle, setLocalFileHandle] = useState(null); - const [showBackgroundPrompt, setShowBackgroundPrompt] = useState(false); + const data = useInstallData(); const { t } = useTranslation(); - const [searchParams, setSearchParams] = useSearchParams(); - const [loaded, setLoaded] = useState(false); - const [doBackwards, setDoBackwards] = useState(false); - - const installOrUpdateScript = async (newScript: Script, code: string) => { - if (newScript.ignoreVersion) newScript.ignoreVersion = ""; - await scriptClient.install({ script: newScript, code }); - const metadata = newScript.metadata; - setScriptInfo((prev) => (prev ? { ...prev, code, metadata } : prev)); - const scriptVersion = metadata.version?.[0]; - const oldScriptVersion = typeof scriptVersion === "string" ? scriptVersion : "N/A"; - setOldScriptVersion(oldScriptVersion); - setUpsertScript(newScript); - setDiffCode(code); - }; - - const getUpdatedNewScript = async (uuid: string, code: string) => { - const oldScript = await scriptClient.info(uuid); - if (!oldScript || oldScript.uuid !== uuid) { - throw new Error("uuid is mismatched"); - } - const { script } = await prepareScriptByCode(code, oldScript.origin || "", uuid); - script.origin = oldScript.origin || script.origin || ""; - if (!script.name) { - throw new Error(t("script_name_cannot_be_set_to_empty")); - } - return script; - }; - - const initAsync = async () => { - try { - const uuid = searchParams.get("uuid"); - const fid = searchParams.get("file"); - - // 如果有 url 或 没有 uuid 和 file,跳过初始化逻辑 - if (searchParams.get("url") || (!uuid && !fid)) { - return; - } - let info: ScriptInfo | undefined; - let isKnownUpdate: boolean = false; - - if (window.history.length > 1) { - setDoBackwards(true); - } - setLoaded(true); - - let paramOptions = {}; - if (uuid) { - const cachedInfo = await scriptClient.getInstallInfo(uuid); - cleanupStaleInstallInfo(uuid); - if (cachedInfo?.[0]) isKnownUpdate = true; - info = cachedInfo?.[1] || undefined; - paramOptions = cachedInfo?.[2] || {}; - if (!info) { - throw new Error("fetch script info failed"); - } - } else { - // 检查是不是本地文件安装 - if (!fid) { - throw new Error("url param - local file id is not found"); - } - const fileHandle = await loadHandle(fid); - if (!fileHandle) { - throw new Error("invalid file access - fileHandle is null"); - } - const file = await fileHandle.getFile(); - if (!file) { - throw new Error("invalid file access - file is null"); - } - // 处理本地文件的安装流程 - // 处理成info对象 - setLocalFileHandle((prev) => { - if (prev instanceof FileSystemFileHandle) unmountFileTrack(prev); - return fileHandle!; - }); - - // 刷新 timestamp, 使 10s~15s 后不会被立即清掉 - // 每五分钟刷新一次db记录的timestamp,使开启中的安装页面的fileHandle不会被刷掉 - intervalExecution(`${cIdKey}liveFileHandle`, () => saveHandle(fid, fileHandle), 5 * 60 * 1000, true); - - const code = await file.text(); - const metadata = parseMetadata(code); - if (!metadata) { - throw new Error("parse script info failed"); - } - info = createScriptInfo(uuidv4(), code, `file:///*from-local*/${file.name}`, "user", metadata); - } - - let prepare: - | { script: Script; oldScript?: Script; oldScriptCode?: string } - | { subscribe: Subscribe; oldSubscribe?: Subscribe }; - let action: Script | Subscribe; - - const { code, url } = info; - let oldVersion: string | undefined = undefined; - let diffCode: string | undefined = undefined; - if (info.userSubscribe) { - prepare = await prepareSubscribeByCode(code, url); - action = prepare.subscribe; - if (prepare.oldSubscribe) { - const oldSubscribeVersion = prepare.oldSubscribe.metadata.version?.[0]; - oldVersion = typeof oldSubscribeVersion === "string" ? oldSubscribeVersion : "N/A"; - } - diffCode = prepare.oldSubscribe?.code; - } else { - const knownUUID = isKnownUpdate ? info.uuid : undefined; - prepare = await prepareScriptByCode(code, url, knownUUID, false, undefined, paramOptions); - action = prepare.script; - if (prepare.oldScript) { - const oldScriptVersion = prepare.oldScript.metadata.version?.[0]; - oldVersion = typeof oldScriptVersion === "string" ? oldScriptVersion : "N/A"; - } - diffCode = prepare.oldScriptCode; - } - setScriptCode(code); - setDiffCode(diffCode); - setOldScriptVersion(typeof oldVersion === "string" ? oldVersion : null); - setIsUpdate(typeof oldVersion === "string"); - setScriptInfo(info); - setUpsertScript(action); - - // 检查是否需要显示后台运行提示 - if (!info.userSubscribe) { - setShowBackgroundPrompt(await checkBackgroundPrompt(action as Script)); - } - } catch (e: any) { - Message.error(t("script_info_load_failed") + " " + e.message); - } finally { - // fileHandle 保留处理方式(暂定): - // fileHandle 会保留一段足够时间,避免用户重新刷画面,重启浏览器等操作后,安装页变得空白一片。 - // 处理会在所有Tab都载入后(不包含睡眠Tab)进行,因此延迟 10s~15s 让处理有足够时间。 - // 安装页面关掉后15分钟为不保留状态,会在安装画面再次打开时(其他脚本安装),进行清除。 - const delay = Math.floor(5000 * Math.random()) + 10000; // 使用乱数时间避免浏览器重启时大量Tabs同时执行DB清除 - timeoutExecution(`${cIdKey}cleanupFileHandle`, cleanupOldHandles, delay); - } - }; - - useEffect(() => { - !loaded && initAsync(); - }, [searchParams, loaded]); - - const [watchFile, setWatchFile] = useState(false); - const metadataLive = useMemo(() => (scriptInfo?.metadata || {}) as SCMetadata, [scriptInfo]); - - const permissions = useMemo(() => { - const permissions: Permission = []; - - if (!scriptInfo) return permissions; - - if (scriptInfo.userSubscribe) { - permissions.push({ - label: t("subscribe_install_label"), - color: "#ff0000", - value: metadataLive.scripturl!, - }); - } - - if (metadataLive.match) { - permissions.push({ label: t("script_runs_in"), value: metadataLive.match }); - } - if (metadataLive.connect) { - permissions.push({ - label: t("script_has_full_access_to"), - color: "#F9925A", - value: metadataLive.connect, - }); - } - - if (metadataLive.require) { - permissions.push({ label: t("script_requires"), value: metadataLive.require }); - } - - return permissions; - }, [scriptInfo, metadataLive, t]); - - const descriptionParagraph = useMemo(() => { - const ret: JSX.Element[] = []; - - if (!scriptInfo) return ret; - - const isCookie = metadataLive.grant?.some((val) => val === "GM_cookie"); - if (isCookie) { - ret.push( - - {t("cookie_warning")} - - ); - } - - if (metadataLive.crontab) { - ret.push({t("scheduled_script_description_title")}); - ret.push( -
    - {t("scheduled_script_description_description_expr")} - {metadataLive.crontab[0]} - {t("scheduled_script_description_description_next")} - {nextTimeDisplay(metadataLive.crontab[0])} -
    - ); - } else if (metadataLive.background) { - ret.push({t("background_script_description")}); - } - - return ret; - }, [scriptInfo, metadataLive, t]); - - const antifeatures: { [key: string]: { color: string; title: string; description: string } } = { - "referral-link": { - color: "purple", - title: t("antifeature_referral_link_title"), - description: t("antifeature_referral_link_description"), - }, - ads: { - color: "orange", - title: t("antifeature_ads_title"), - description: t("antifeature_ads_description"), - }, - payment: { - color: "magenta", - title: t("antifeature_payment_title"), - description: t("antifeature_payment_description"), - }, - miner: { - color: "orangered", - title: t("antifeature_miner_title"), - description: t("antifeature_miner_description"), - }, - membership: { - color: "blue", - title: t("antifeature_membership_title"), - description: t("antifeature_membership_description"), - }, - tracking: { - color: "pinkpurple", - title: t("antifeature_tracking_title"), - description: t("antifeature_tracking_description"), - }, - }; - - // 更新按钮文案和页面标题 - useEffect(() => { - if (scriptInfo?.userSubscribe) { - setBtnText(isUpdate ? t("update_subscribe")! : t("install_subscribe")); - } else { - setBtnText(isUpdate ? t("update_script")! : t("install_script")); - } - if (upsertScript) { - document.title = `${!isUpdate ? t("install_script") : t("update_script")} - ${i18nName(upsertScript!)} - ScriptCat`; - } - }, [isUpdate, scriptInfo, upsertScript, t]); - - // 设置脚本状态 - useEffect(() => { - if (upsertScript) { - setEnable(upsertScript.status === SCRIPT_STATUS_ENABLE); - } - }, [upsertScript]); - - // 检查是否需要显示后台运行提示 - const checkBackgroundPrompt = async (script: Script) => { - // 只有后台脚本或定时脚本才提示 - if (!script.metadata.background && !script.metadata.crontab) { - return false; - } - - // 检查是否首次安装或更新 - const hasShown = localStorage.getItem(backgroundPromptShownKey); - - if (hasShown !== "true") { - // 检查是否已经有后台权限 - const permission = await isPermissionOk("background"); - if (permission === false) return true; // optional permission "background" 需要显示后台运行提示 - } - return false; - }; - - const handleInstall = async (options: { closeAfterInstall?: boolean; noMoreUpdates?: boolean } = {}) => { - if (!upsertScript) { - Message.error(t("script_info_load_failed")!); - return; - } - - const { closeAfterInstall: shouldClose = true, noMoreUpdates: disableUpdates = false } = options; - - try { - if (scriptInfo?.userSubscribe) { - await subscribeClient.install(upsertScript as Subscribe); // 首次安装时,upsertScript 里的 scripts 为空物件 - Message.success(t("subscribe_success")!); - setBtnText(t("subscribe_success")!); - } else { - // 如果选择不再检查更新,可以在这里设置脚本的更新配置 - if (disableUpdates && upsertScript) { - // 这里可以设置脚本禁用自动更新的逻辑 - (upsertScript as Script).checkUpdate = false; - } - // 故意只安装或执行,不改变显示内容 - await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); - if (isUpdate) { - Message.success(t("install.update_success")!); - setBtnText(t("install.update_success")!); - } else { - // 如果选择不再检查更新,可以在这里设置脚本的更新配置 - if (disableUpdates && upsertScript) { - // 这里可以设置脚本禁用自动更新的逻辑 - (upsertScript as Script).checkUpdate = false; - } - if ((upsertScript as Script).ignoreVersion) (upsertScript as Script).ignoreVersion = ""; - // 故意只安装或执行,不改变显示内容 - await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); - if (isUpdate) { - Message.success(t("install.update_success")!); - setBtnText(t("install.update_success")!); - } else { - Message.success(t("install_success")!); - setBtnText(t("install_success")!); - } - } - } - - if (shouldClose) { - setTimeout(() => { - closeWindow(doBackwards); - }, 500); - } - } catch (e) { - const errorMessage = scriptInfo?.userSubscribe ? t("subscribe_failed") : t("install_failed"); - Message.error(`${errorMessage}: ${e}`); - } - }; - - const handleClose = (options?: { noMoreUpdates: boolean }) => { - const { noMoreUpdates = false } = options || {}; - if (noMoreUpdates && scriptInfo && !scriptInfo.userSubscribe) { - scriptClient.setCheckUpdateUrl(scriptInfo.uuid, false); - } - closeWindow(doBackwards); - }; - - const { - handleInstallBasic, - handleInstallCloseAfterInstall, - handleInstallNoMoreUpdates, - handleStatusChange, - handleCloseBasic, - handleCloseNoMoreUpdates, - setWatchFileClick, - } = { - handleInstallBasic: () => handleInstall(), - handleInstallCloseAfterInstall: () => handleInstall({ closeAfterInstall: false }), - handleInstallNoMoreUpdates: () => handleInstall({ noMoreUpdates: true }), - handleStatusChange: (checked: boolean) => { - setUpsertScript((script) => { - if (!script) { - return script; - } - script.status = checked ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; - setEnable(checked); - return script; - }); - }, - handleCloseBasic: () => handleClose(), - handleCloseNoMoreUpdates: () => handleClose({ noMoreUpdates: true }), - setWatchFileClick: () => { - setWatchFile((prev) => !prev); - }, - }; - - const fileWatchMessageId = `id_${Math.random()}`; - - async function onWatchFileCodeChanged(this: FTInfo, code: string, hideInfo: boolean = false) { - if (this.uuid !== scriptInfo?.uuid) return; - if (this.fileName !== localFileHandle?.name) return; - setScriptCode(code); - const uuid = (upsertScript as Script)?.uuid; - if (!uuid) { - throw new Error("uuid is undefined"); - } - try { - const newScript = await getUpdatedNewScript(uuid, code); - await installOrUpdateScript(newScript, code); - } catch (e) { - Message.error({ - id: fileWatchMessageId, - content: t("install_failed") + ": " + e, - }); - return; - } - if (!hideInfo) { - Message.info({ - id: fileWatchMessageId, - content: `${t("last_updated")}: ${dayFormat()}`, - duration: 3000, - closable: true, - showIcon: true, - }); - } - } - - async function onWatchFileError() { - // e.g. NotFoundError - setWatchFile(false); + // Skill ZIP 安装 + if (data.skillPreview) { + return ( + + ); } - const memoWatchFile = useMemo(() => { - return `${watchFile}.${scriptInfo?.uuid}.${localFileHandle?.name}`; - }, [watchFile, scriptInfo, localFileHandle]); - - const setupWatchFile = async (uuid: string, fileName: string, handle: FileSystemFileHandle) => { - try { - // 如没有安装纪录,将进行安装。 - // 如已经安装,在FileSystemObserver检查更改前,先进行更新。 - const code = `${scriptCode}`; - await installOrUpdateScript(upsertScript as Script, code); - // setScriptCode(`${code}`); - setDiffCode(`${code}`); - const ftInfo: FTInfo = { - uuid, - fileName, - setCode: onWatchFileCodeChanged, - onFileError: onWatchFileError, - }; - // 进行监听 - startFileTrack(handle, ftInfo); - // 先取最新代码 - const file = await handle.getFile(); - const currentCode = await file.text(); - // 如不一致,先更新 - if (currentCode !== code) { - ftInfo.setCode(currentCode, true); - } - } catch (e: any) { - Message.error(`${e.message}`); - console.warn(e); - } - }; - - useEffect(() => { - if (!watchFile || !localFileHandle) { - return; - } - // 去除React特性 - const [handle] = [localFileHandle]; - unmountFileTrack(handle); // 避免重复追踪 - const uuid = scriptInfo?.uuid; - const fileName = handle?.name; - if (!uuid || !fileName) { - return; - } - setupWatchFile(uuid, fileName, handle); - return () => { - unmountFileTrack(handle); - }; - }, [memoWatchFile]); - - // 检查是否有 uuid 或 file - const searchParamUrl = searchParams.get("url"); - const hasValidSourceParam = !searchParamUrl && !!(searchParams.get("uuid") || searchParams.get("file")); - - const urlHref = useMemo(() => { - if (searchParamUrl) { - try { - // 取url=之后的所有内容 - const idx = location.search.indexOf("url="); - const rawUrl = idx !== -1 ? location.search.slice(idx + 4) : searchParamUrl; - const urlObject = new URL(rawUrl); - // 验证解析后的 URL 是否具备核心要素,确保安全性与合法性 - if (urlObject.protocol && urlObject.hostname && urlObject.pathname) { - return rawUrl; - } - } catch { - // ignored - } - } - return ""; - }, [searchParamUrl]); - - const [fetchingState, setFetchingState] = useState({ - loadingStatus: "", - errorStatus: "", - }); - - const loadURLAsync = async (url: string) => { - // 1. 定义获取单个脚本的内部逻辑,负责处理进度条与单次错误 - const fetchValidScript = async () => { - const result = await fetchScriptBody(url, { - onProgress: (info: { receivedLength: number }) => { - setFetchingState((prev) => ({ - ...prev, - loadingStatus: t("downloading_status_text", { bytes: formatBytes(info.receivedLength) }), - })); - }, - }); - if (result.code && result.metadata) { - return { result, url } as const; // 找到有效的立即返回 - } - throw new Error(t("install_page_load_failed")); - }; - - try { - // 2. 执行获取 - const { result, url } = await fetchValidScript(); - const { code, metadata } = result; - - // 3. 处理数据与缓存 - const uuid = uuidv4(); - const scriptData = [false, createScriptInfo(uuid, code, url, "user", metadata)]; - - await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, scriptData); - - // 4. 更新导向 - setSearchParams(new URLSearchParams(`?uuid=${uuid}`), { replace: true }); - } catch (err: any) { - // 5. 统一错误处理 - setFetchingState((prev) => ({ - ...prev, - loadingStatus: "", - errorStatus: `${err?.message || err}`, - })); - } - }; - - const handleUrlChangeAndFetch = (targetUrlHref: string) => { - setFetchingState((prev) => ({ - ...prev, - loadingStatus: t("install_page_please_wait"), - })); - loadURLAsync(targetUrlHref); - }; - - // 有 url 的话下载内容 - useEffect(() => { - if (urlHref) handleUrlChangeAndFetch(urlHref); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlHref]); - - if (!hasValidSourceParam) { - return urlHref ? ( + // URL 加载中 / 错误 / 无效页面 + if (!data.hasValidSourceParam) { + return data.urlHref ? (
    - {fetchingState.loadingStatus && ( + {data.fetchingState.loadingStatus && ( <> {t("install_page_loading")}
    - {fetchingState.loadingStatus} + {data.fetchingState.loadingStatus}
    )} - {fetchingState.errorStatus && ( + {data.fetchingState.errorStatus && ( <> {t("install_page_load_failed")} -
    {fetchingState.errorStatus}
    +
    {data.fetchingState.errorStatus}
    )}
    @@ -753,249 +54,8 @@ function App() { ); } - return ( -
    - {/* 后台运行提示对话框 */} - { - try { - const granted = await chrome.permissions.request({ permissions: ["background"] }); - if (granted) { - Message.success(t("enable_background.title")!); - } else { - Message.info(t("enable_background.maybe_later")!); - } - setShowBackgroundPrompt(false); - localStorage.setItem(backgroundPromptShownKey, "true"); - } catch (e) { - console.error(e); - Message.error(t("enable_background.enable_failed")!); - } - }} - onCancel={() => { - setShowBackgroundPrompt(false); - localStorage.setItem(backgroundPromptShownKey, "true"); - }} - okText={t("enable_background.enable_now")} - cancelText={t("enable_background.maybe_later")} - autoFocus={false} - focusLock={true} - > - - - {t("enable_background.prompt_description", { - scriptType: upsertScript?.metadata?.background ? t("background_script") : t("scheduled_script"), - })} - - {t("enable_background.settings_hint")} - - -
    -
    - {upsertScript?.metadata.icon && } - {upsertScript && ( - - - {i18nName(upsertScript)} - - - )} - - - -
    -
    -
    - {oldScriptVersion && ( - - {oldScriptVersion} - - )} - {typeof metadataLive.version?.[0] === "string" && metadataLive.version[0] !== oldScriptVersion && ( - - - {metadataLive.version[0]} - - - )} -
    -
    -
    -
    -
    -
    -
    -
    - {(metadataLive.background || metadataLive.crontab) && ( - - - {t("background_script")} - - - )} - {metadataLive.crontab && ( - - - {t("scheduled_script")} - - - )} - {metadataLive.antifeature?.length && - metadataLive.antifeature.map((antifeature) => { - const item = antifeature.split(" ")[0]; - return ( - antifeatures[item] && ( - - - {antifeatures[item].title} - - - ) - ); - })} -
    -
    -
    - {upsertScript && i18nDescription(upsertScript!)} -
    -
    - {`${t("author")}: ${metadataLive.author}`} -
    -
    - - {`${t("source")}: ${prettyUrl(scriptInfo?.url)}`} - -
    -
    -
    -
    - {descriptionParagraph?.length ? ( -
    - - - {descriptionParagraph} - - -
    - ) : ( - <> - )} -
    - {permissions.map((item) => ( -
    - {item.value?.length > 0 ? ( - <> - - {item.label} - -
    - {item.value.map((v) => ( -
    - {v} -
    - ))} -
    - - ) : ( - <> - )} -
    - ))} -
    -
    -
    -
    - {t("install_from_legitimate_sources_warning")} -
    -
    - - - - - - {isUpdate ? t("update_script_no_close") : t("install_script_no_close")} - - {!scriptInfo?.userSubscribe && ( - - {isUpdate ? t("update_script_no_more_update") : t("install_script_no_more_update")} - - )} - - } - position="bottom" - disabled={watchFile} - > - - - )} - {isUpdate ? ( - - - - {!scriptInfo?.userSubscribe && ( - - {t("close_update_script_no_more_update")} - - )} - - } - position="bottom" - > - - )} - -
    -
    -
    - -
    -
    -
    - ); + // UserScript / Subscribe 安装 + return ; } export default App; diff --git a/src/pages/install/components/ScriptInstallView.tsx b/src/pages/install/components/ScriptInstallView.tsx new file mode 100644 index 000000000..01e3c6626 --- /dev/null +++ b/src/pages/install/components/ScriptInstallView.tsx @@ -0,0 +1,296 @@ +import { + Button, + Dropdown, + Menu, + Modal, + Space, + Switch, + Tag, + Tooltip, + Typography, + Popover, + Message, +} from "@arco-design/web-react"; +import { IconDown } from "@arco-design/web-react/icon"; +import { useTranslation } from "react-i18next"; +import CodeEditor from "../../components/CodeEditor"; +import { i18nName, i18nDescription } from "@App/locales/locales"; +import { ScriptIcons } from "../../options/routes/utils"; +import { prettyUrl } from "@App/pkg/utils/url-utils"; +import { backgroundPromptShownKey } from "../utils"; +import type { InstallData } from "../hooks"; + +function ScriptInstallView({ data }: { data: InstallData }) { + const { + enable, + btnText, + scriptCode, + scriptInfo, + upsertScript, + diffCode, + oldScriptVersion, + isUpdate, + localFileHandle, + showBackgroundPrompt, + setShowBackgroundPrompt, + watchFile, + metadataLive, + permissions, + descriptionParagraph, + antifeatures, + handleInstallBasic, + handleInstallCloseAfterInstall, + handleInstallNoMoreUpdates, + handleStatusChange, + handleCloseBasic, + handleCloseNoMoreUpdates, + setWatchFileClick, + } = data; + const { t } = useTranslation(); + + return ( +
    + {/* 后台运行提示对话框 */} + { + try { + const granted = await chrome.permissions.request({ permissions: ["background"] }); + if (granted) { + Message.success(t("enable_background.title")!); + } else { + Message.info(t("enable_background.maybe_later")!); + } + setShowBackgroundPrompt(false); + localStorage.setItem(backgroundPromptShownKey, "true"); + } catch (e) { + console.error(e); + Message.error(t("enable_background.enable_failed")!); + } + }} + onCancel={() => { + setShowBackgroundPrompt(false); + localStorage.setItem(backgroundPromptShownKey, "true"); + }} + okText={t("enable_background.enable_now")} + cancelText={t("enable_background.maybe_later")} + autoFocus={false} + focusLock={true} + > + + + {t("enable_background.prompt_description", { + scriptType: upsertScript?.metadata?.background ? t("background_script") : t("scheduled_script"), + })} + + {t("enable_background.settings_hint")} + + +
    +
    + {upsertScript?.metadata.icon && } + {upsertScript && ( + + + {i18nName(upsertScript)} + + + )} + + + +
    +
    +
    + {oldScriptVersion && ( + + {oldScriptVersion} + + )} + {typeof metadataLive.version?.[0] === "string" && metadataLive.version[0] !== oldScriptVersion && ( + + + {metadataLive.version[0]} + + + )} +
    +
    +
    +
    +
    +
    +
    +
    + {(metadataLive.background || metadataLive.crontab) && ( + + + {t("background_script")} + + + )} + {metadataLive.crontab && ( + + + {t("scheduled_script")} + + + )} + {metadataLive.antifeature?.length && + metadataLive.antifeature.map((antifeature) => { + const item = antifeature.split(" ")[0]; + return ( + antifeatures[item] && ( + + + {antifeatures[item].title} + + + ) + ); + })} +
    +
    +
    + {upsertScript && i18nDescription(upsertScript!)} +
    +
    + {`${t("author")}: ${metadataLive.author}`} +
    +
    + + {`${t("source")}: ${prettyUrl(scriptInfo?.url)}`} + +
    +
    +
    +
    + {descriptionParagraph?.length ? ( +
    + + + {descriptionParagraph} + + +
    + ) : ( + <> + )} +
    + {permissions.map((item) => ( +
    + {item.value?.length > 0 ? ( + <> + + {item.label} + +
    + {item.value.map((v) => ( +
    + {v} +
    + ))} +
    + + ) : ( + <> + )} +
    + ))} +
    +
    +
    +
    + {t("install_from_legitimate_sources_warning")} +
    +
    + + + + + + {isUpdate ? t("update_script_no_close") : t("install_script_no_close")} + + {!scriptInfo?.userSubscribe && ( + + {isUpdate ? t("update_script_no_more_update") : t("install_script_no_more_update")} + + )} + + } + position="bottom" + disabled={watchFile} + > + + + )} + {isUpdate ? ( + + + + {!scriptInfo?.userSubscribe && ( + + {t("close_update_script_no_more_update")} + + )} + + } + position="bottom" + > + + )} + +
    +
    +
    + +
    +
    +
    + ); +} + +export default ScriptInstallView; diff --git a/src/pages/install/components/SkillInstallView.tsx b/src/pages/install/components/SkillInstallView.tsx new file mode 100644 index 000000000..d3898da1a --- /dev/null +++ b/src/pages/install/components/SkillInstallView.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +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 { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; +import type { SkillConfigField } from "@App/app/service/agent/core/types"; + +interface SkillInstallViewProps { + metadata: { name: string; description: string; config?: Record }; + prompt: string; + scripts: Array<{ name: string; code: string }>; + references: Array<{ name: string; content: string }>; + isUpdate: boolean; + onInstall: () => void; + onClose: () => void; +} + +function SkillInstallView({ + metadata, + prompt, + scripts, + references, + isUpdate, + onInstall, + onClose, +}: SkillInstallViewProps) { + const { t } = useTranslation(); + const [promptExpanded, setPromptExpanded] = useState(false); + + return ( +
    + {/* Header */} +
    +
    + + {"Skill"} + + + {metadata.name} + + {isUpdate && ( + + {t("update")} + + )} +
    +
    + + {/* Content */} +
    +
    +
    + {/* Description */} + {metadata.description && ( +
    + {metadata.description} +
    + )} + + {/* Prompt */} + {prompt && ( +
    +
    setPromptExpanded(!promptExpanded)} + > + + {t("agent_skills_prompt")} + {":"} + + {promptExpanded ? : } +
    + {promptExpanded ? ( +
    +
    +                      {prompt}
    +                    
    +
    + ) : ( +
    + + {prompt.length > 150 ? prompt.slice(0, 150) + "..." : prompt} + +
    + )} +
    + )} + + {/* Tools */} + {scripts.length > 0 && ( +
    + {`${t("agent_skills_tools")} (${scripts.length}):`} +
    + {scripts.map((script) => { + const toolMeta = parseSkillScriptMetadata(script.code); + return ( +
    +
    + + {toolMeta?.name || script.name} + +
    + {toolMeta?.description && ( + + {toolMeta.description} + + )} + {toolMeta && toolMeta.params.length > 0 && ( +
    + {toolMeta.params.map((param) => ( +
    + + {param.name} + + + {param.type} + + {param.required && ( + + {t("skill_script_required")} + + )} + {param.description && ( + + {param.description} + + )} +
    + ))} +
    + )} + {toolMeta && toolMeta.grants.length > 0 && ( +
    + {toolMeta.grants.map((grant) => ( + + {grant} + + ))} +
    + )} +
    + ); + })} +
    +
    + )} + + {/* 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("skill_script_required")} + + )} + {field.secret && ( + + {"secret"} + + )} +
    + {field.title && ( + + {field.title} + + )} +
    + ))} +
    +
    + )} + + {/* References */} + {references.length > 0 && ( +
    + {`${t("agent_skills_references")} (${references.length}):`} +
    + {references.map((ref) => ( + + {ref.name} + + ))} +
    +
    + )} +
    +
    + + {/* Warning + Actions */} +
    +
    + {t("install_from_legitimate_sources_warning")} +
    +
    + + + + +
    +
    +
    +
    + ); +} + +export default SkillInstallView; diff --git a/src/pages/install/hooks.tsx b/src/pages/install/hooks.tsx new file mode 100644 index 000000000..f10a81bf2 --- /dev/null +++ b/src/pages/install/hooks.tsx @@ -0,0 +1,667 @@ +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Message, Typography } from "@arco-design/web-react"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { SCMetadata, Script } from "@App/app/repo/scripts"; +import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE } from "@App/app/repo/scripts"; +import type { Subscribe } from "@App/app/repo/subscribe"; +import { createScriptInfo, type ScriptInfo } from "@App/pkg/utils/scriptInstall"; +import { parseMetadata, prepareScriptByCode, prepareSubscribeByCode } from "@App/pkg/utils/script"; +import { nextTimeDisplay } from "@App/pkg/utils/cron"; +import { scriptClient, subscribeClient, agentClient } from "../store/features/script"; +import { type FTInfo, startFileTrack, unmountFileTrack } from "@App/pkg/utils/file-tracker"; +import { cleanupOldHandles, loadHandle, saveHandle } from "@App/pkg/utils/filehandle-db"; +import { dayFormat } from "@App/pkg/utils/day_format"; +import { intervalExecution, timeoutExecution } from "@App/pkg/utils/timer"; +import { CACHE_KEY_SCRIPT_INFO } from "@App/app/cache_key"; +import { cacheInstance } from "@App/app/cache"; +import { formatBytes } from "@App/pkg/utils/utils"; +import { i18nName } from "@App/locales/locales"; +import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; +import type { SkillScriptMetadata } from "@App/app/service/agent/core/types"; +import { + cIdKey, + backgroundPromptShownKey, + closeWindow, + fetchScriptBody, + cleanupStaleInstallInfo, + type Permission, +} from "./utils"; + +type ScriptOrSubscribe = Script | Subscribe; + +export function useInstallData() { + const [enable, setEnable] = useState(false); + const [btnText, setBtnText] = useState(""); + const [scriptCode, setScriptCode] = useState(""); + const [scriptInfo, setScriptInfo] = useState(); + const [upsertScript, setUpsertScript] = useState(undefined); + const [diffCode, setDiffCode] = useState(); + const [oldScriptVersion, setOldScriptVersion] = useState(null); + const [isUpdate, setIsUpdate] = useState(false); + const [localFileHandle, setLocalFileHandle] = useState(null); + const [showBackgroundPrompt, setShowBackgroundPrompt] = useState(false); + const { t } = useTranslation(); + const [searchParams, setSearchParams] = useSearchParams(); + const [loaded, setLoaded] = useState(false); + const [doBackwards, setDoBackwards] = useState(false); + const [skillScriptMetadata, setSkillScriptMetadata] = useState(null); + const [watchFile, setWatchFile] = useState(false); + + // Skill 安装相关状态 + const skillInstallUuid = searchParams.get("skill"); + const [skillPreview, setSkillPreview] = useState<{ + metadata: { name: string; description: string }; + prompt: string; + scripts: Array<{ name: string; code: string }>; + references: Array<{ name: string; content: string }>; + isUpdate: boolean; + } | null>(null); + + const installOrUpdateScript = async (newScript: Script, code: string) => { + if (newScript.ignoreVersion) newScript.ignoreVersion = ""; + await scriptClient.install({ script: newScript, code }); + const metadata = newScript.metadata; + setScriptInfo((prev) => (prev ? { ...prev, code, metadata } : prev)); + const scriptVersion = metadata.version?.[0]; + const oldScriptVersion = typeof scriptVersion === "string" ? scriptVersion : "N/A"; + setOldScriptVersion(oldScriptVersion); + setUpsertScript(newScript); + setDiffCode(code); + }; + + const getUpdatedNewScript = async (uuid: string, code: string) => { + const oldScript = await scriptClient.info(uuid); + if (!oldScript || oldScript.uuid !== uuid) { + throw new Error("uuid is mismatched"); + } + const { script } = await prepareScriptByCode(code, oldScript.origin || "", uuid); + script.origin = oldScript.origin || script.origin || ""; + if (!script.name) { + throw new Error(t("script_name_cannot_be_set_to_empty")); + } + return script; + }; + + const checkBackgroundPrompt = async (script: Script) => { + if (!script.metadata.background && !script.metadata.crontab) { + return false; + } + const hasShown = localStorage.getItem(backgroundPromptShownKey); + if (hasShown !== "true") { + if (!(await chrome.permissions.contains({ permissions: ["background"] }))) { + return true; + } + } + return false; + }; + + // Skill ZIP 安装:从缓存加载并解析 + const initSkillFromCache = async (uuid: string) => { + try { + setLoaded(true); + if (window.history.length > 1) { + setDoBackwards(true); + } + const data = await agentClient.getSkillInstallData(uuid); + setSkillPreview(data); + } catch (e: any) { + Message.error(t("script_info_load_failed") + " " + e.message); + } + }; + + // Skill 安装确认 + const handleSkillInstall = async () => { + if (!skillInstallUuid) return; + try { + await agentClient.completeSkillInstall(skillInstallUuid); + Message.success(t("install_success")!); + setTimeout(() => { + closeWindow(doBackwards); + }, 500); + } catch (e) { + Message.error(`${t("install_failed")}: ${e}`); + } + }; + + // Skill 安装取消 + const handleSkillCancel = () => { + if (!skillInstallUuid) return; + agentClient.cancelSkillInstall(skillInstallUuid); + closeWindow(doBackwards); + }; + + const initAsync = async () => { + try { + const uuid = searchParams.get("uuid"); + const fid = searchParams.get("file"); + + // 如果有 url 或 没有 uuid 和 file,跳过初始化逻辑 + if (searchParams.get("url") || (!uuid && !fid)) { + return; + } + let info: ScriptInfo | undefined; + let isKnownUpdate: boolean = false; + + if (window.history.length > 1) { + setDoBackwards(true); + } + setLoaded(true); + + let paramOptions = {}; + if (uuid) { + const cachedInfo = await scriptClient.getInstallInfo(uuid); + cleanupStaleInstallInfo(uuid); + if (cachedInfo?.[0]) isKnownUpdate = true; + info = cachedInfo?.[1] || undefined; + paramOptions = cachedInfo?.[2] || {}; + if (!info) { + throw new Error("fetch script info failed"); + } + } else { + // 检查是不是本地文件安装 + if (!fid) { + throw new Error("url param - local file id is not found"); + } + const fileHandle = await loadHandle(fid); + if (!fileHandle) { + throw new Error("invalid file access - fileHandle is null"); + } + const file = await fileHandle.getFile(); + if (!file) { + throw new Error("invalid file access - file is null"); + } + // 处理本地文件的安装流程 + setLocalFileHandle((prev) => { + if (prev instanceof FileSystemFileHandle) unmountFileTrack(prev); + return fileHandle!; + }); + + // 刷新 timestamp, 使 10s~15s 后不会被立即清掉 + intervalExecution(`${cIdKey}liveFileHandle`, () => saveHandle(fid, fileHandle), 5 * 60 * 1000, true); + + const code = await file.text(); + const metadata = parseMetadata(code); + if (!metadata) { + // 非 UserScript,尝试作为 SkillScript 处理 + const skillScriptMeta = parseSkillScriptMetadata(code); + if (!skillScriptMeta) { + throw new Error("parse script info failed"); + } + info = createScriptInfo(uuidv4(), code, `file:///*from-local*/${file.name}`, "user", {} as SCMetadata); + info.skillScript = true; + } else { + info = createScriptInfo(uuidv4(), code, `file:///*from-local*/${file.name}`, "user", metadata); + } + } + + // SkillScript 安装:只需解析元数据并展示 + if (info.skillScript) { + const toolMeta = parseSkillScriptMetadata(info.code); + if (!toolMeta) { + throw new Error("Invalid SkillScript: missing or malformed ==SkillScript== header"); + } + setSkillScriptMetadata(toolMeta); + setScriptCode(info.code); + setScriptInfo(info); + return; + } + + let prepare: + | { script: Script; oldScript?: Script; oldScriptCode?: string } + | { subscribe: Subscribe; oldSubscribe?: Subscribe }; + let action: Script | Subscribe; + + const { code, url } = info; + let oldVersion: string | undefined = undefined; + let diffCode: string | undefined = undefined; + if (info.userSubscribe) { + prepare = await prepareSubscribeByCode(code, url); + action = prepare.subscribe; + if (prepare.oldSubscribe) { + const oldSubscribeVersion = prepare.oldSubscribe.metadata.version?.[0]; + oldVersion = typeof oldSubscribeVersion === "string" ? oldSubscribeVersion : "N/A"; + } + diffCode = prepare.oldSubscribe?.code; + } else { + const knownUUID = isKnownUpdate ? info.uuid : undefined; + prepare = await prepareScriptByCode(code, url, knownUUID, false, undefined, paramOptions); + action = prepare.script; + if (prepare.oldScript) { + const oldScriptVersion = prepare.oldScript.metadata.version?.[0]; + oldVersion = typeof oldScriptVersion === "string" ? oldScriptVersion : "N/A"; + } + diffCode = prepare.oldScriptCode; + } + setScriptCode(code); + setDiffCode(diffCode); + setOldScriptVersion(typeof oldVersion === "string" ? oldVersion : null); + setIsUpdate(typeof oldVersion === "string"); + setScriptInfo(info); + setUpsertScript(action); + + // 检查是否需要显示后台运行提示 + if (!info.userSubscribe) { + setShowBackgroundPrompt(await checkBackgroundPrompt(action as Script)); + } + } catch (e: any) { + Message.error(t("script_info_load_failed") + " " + e.message); + } finally { + const delay = Math.floor(5000 * Math.random()) + 10000; + timeoutExecution(`${cIdKey}cleanupFileHandle`, cleanupOldHandles, delay); + } + }; + + useEffect(() => { + if (loaded) return; + if (skillInstallUuid) { + initSkillFromCache(skillInstallUuid); + } else { + initAsync(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, loaded]); + + const metadataLive = useMemo(() => (scriptInfo?.metadata || {}) as SCMetadata, [scriptInfo]); + + const permissions = useMemo(() => { + const permissions: Permission = []; + + if (!scriptInfo) return permissions; + + if (scriptInfo.userSubscribe) { + permissions.push({ + label: t("subscribe_install_label"), + color: "#ff0000", + value: metadataLive.scripturl!, + }); + } + + if (metadataLive.match) { + permissions.push({ label: t("script_runs_in"), value: metadataLive.match }); + } + + if (metadataLive.connect) { + permissions.push({ + label: t("script_has_full_access_to"), + color: "#F9925A", + value: metadataLive.connect, + }); + } + + if (metadataLive.require) { + permissions.push({ label: t("script_requires"), value: metadataLive.require }); + } + + return permissions; + }, [scriptInfo, metadataLive, t]); + + const descriptionParagraph = useMemo(() => { + const ret: JSX.Element[] = []; + + if (!scriptInfo) return ret; + + const isCookie = metadataLive.grant?.some((val: string) => val === "GM_cookie"); + if (isCookie) { + ret.push( + + {t("cookie_warning")} + + ); + } + + if (metadataLive.crontab) { + ret.push({t("scheduled_script_description_title")}); + ret.push( +
    + {t("scheduled_script_description_description_expr")} + {metadataLive.crontab[0]} + {t("scheduled_script_description_description_next")} + {nextTimeDisplay(metadataLive.crontab[0])} +
    + ); + } else if (metadataLive.background) { + ret.push({t("background_script_description")}); + } + + return ret; + }, [scriptInfo, metadataLive, t]); + + const antifeatures: { [key: string]: { color: string; title: string; description: string } } = { + "referral-link": { + color: "purple", + title: t("antifeature_referral_link_title"), + description: t("antifeature_referral_link_description"), + }, + ads: { + color: "orange", + title: t("antifeature_ads_title"), + description: t("antifeature_ads_description"), + }, + payment: { + color: "magenta", + title: t("antifeature_payment_title"), + description: t("antifeature_payment_description"), + }, + miner: { + color: "orangered", + title: t("antifeature_miner_title"), + description: t("antifeature_miner_description"), + }, + membership: { + color: "blue", + title: t("antifeature_membership_title"), + description: t("antifeature_membership_description"), + }, + tracking: { + color: "pinkpurple", + title: t("antifeature_tracking_title"), + description: t("antifeature_tracking_description"), + }, + }; + + // 更新按钮文案和页面标题 + useEffect(() => { + if (skillPreview) { + document.title = `${t("install_script")} - ${skillPreview.metadata.name} - ScriptCat`; + return; + } + if (scriptInfo?.skillScript && skillScriptMetadata) { + document.title = `${t("install_script")} - ${skillScriptMetadata.name} - ScriptCat`; + return; + } + if (scriptInfo?.userSubscribe) { + setBtnText(isUpdate ? t("update_subscribe")! : t("install_subscribe")); + } else { + setBtnText(isUpdate ? t("update_script")! : t("install_script")); + } + 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, skillScriptMetadata, t]); + + // 设置脚本状态 + useEffect(() => { + if (upsertScript) { + setEnable(upsertScript.status === SCRIPT_STATUS_ENABLE); + } + }, [upsertScript]); + + const handleInstall = async (options: { closeAfterInstall?: boolean; noMoreUpdates?: boolean } = {}) => { + if (!upsertScript) { + Message.error(t("script_info_load_failed")!); + return; + } + + const { closeAfterInstall: shouldClose = true, noMoreUpdates: disableUpdates = false } = options; + + try { + if (scriptInfo?.userSubscribe) { + await subscribeClient.install(upsertScript as Subscribe); + Message.success(t("subscribe_success")!); + setBtnText(t("subscribe_success")!); + } else { + if (disableUpdates && upsertScript) { + (upsertScript as Script).checkUpdate = false; + } + await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); + if (isUpdate) { + Message.success(t("install.update_success")!); + setBtnText(t("install.update_success")!); + } else { + if (disableUpdates && upsertScript) { + (upsertScript as Script).checkUpdate = false; + } + if ((upsertScript as Script).ignoreVersion) (upsertScript as Script).ignoreVersion = ""; + await scriptClient.install({ script: upsertScript as Script, code: scriptCode }); + if (isUpdate) { + Message.success(t("install.update_success")!); + setBtnText(t("install.update_success")!); + } else { + Message.success(t("install_success")!); + setBtnText(t("install_success")!); + } + } + } + + if (shouldClose) { + setTimeout(() => { + closeWindow(doBackwards); + }, 500); + } + } catch (e) { + const errorMessage = scriptInfo?.userSubscribe ? t("subscribe_failed") : t("install_failed"); + Message.error(`${errorMessage}: ${e}`); + } + }; + + const handleClose = (options?: { noMoreUpdates: boolean }) => { + const { noMoreUpdates = false } = options || {}; + if (noMoreUpdates && scriptInfo && !scriptInfo.userSubscribe) { + scriptClient.setCheckUpdateUrl(scriptInfo.uuid, false); + } + closeWindow(doBackwards); + }; + + const handleInstallBasic = () => handleInstall(); + const handleInstallCloseAfterInstall = () => handleInstall({ closeAfterInstall: false }); + const handleInstallNoMoreUpdates = () => handleInstall({ noMoreUpdates: true }); + const handleStatusChange = (checked: boolean) => { + setUpsertScript((script) => { + if (!script) { + return script; + } + script.status = checked ? SCRIPT_STATUS_ENABLE : SCRIPT_STATUS_DISABLE; + setEnable(checked); + return script; + }); + }; + const handleCloseBasic = () => handleClose(); + const handleCloseNoMoreUpdates = () => handleClose({ noMoreUpdates: true }); + const setWatchFileClick = () => { + setWatchFile((prev) => !prev); + }; + + const fileWatchMessageId = `id_${Math.random()}`; + + async function onWatchFileCodeChanged(this: FTInfo, code: string, hideInfo: boolean = false) { + if (this.uuid !== scriptInfo?.uuid) return; + if (this.fileName !== localFileHandle?.name) return; + setScriptCode(code); + const uuid = (upsertScript as Script)?.uuid; + if (!uuid) { + throw new Error("uuid is undefined"); + } + try { + const newScript = await getUpdatedNewScript(uuid, code); + await installOrUpdateScript(newScript, code); + } catch (e) { + Message.error({ + id: fileWatchMessageId, + content: t("install_failed") + ": " + e, + }); + return; + } + if (!hideInfo) { + Message.info({ + id: fileWatchMessageId, + content: `${t("last_updated")}: ${dayFormat()}`, + duration: 3000, + closable: true, + showIcon: true, + }); + } + } + + async function onWatchFileError() { + setWatchFile(false); + } + + const memoWatchFile = useMemo(() => { + return `${watchFile}.${scriptInfo?.uuid}.${localFileHandle?.name}`; + }, [watchFile, scriptInfo, localFileHandle]); + + const setupWatchFile = async (uuid: string, fileName: string, handle: FileSystemFileHandle) => { + try { + const code = `${scriptCode}`; + await installOrUpdateScript(upsertScript as Script, code); + setDiffCode(`${code}`); + const ftInfo: FTInfo = { + uuid, + fileName, + setCode: onWatchFileCodeChanged, + onFileError: onWatchFileError, + }; + startFileTrack(handle, ftInfo); + const file = await handle.getFile(); + const currentCode = await file.text(); + if (currentCode !== code) { + ftInfo.setCode(currentCode, true); + } + } catch (e: any) { + Message.error(`${e.message}`); + console.warn(e); + } + }; + + useEffect(() => { + if (!watchFile || !localFileHandle) { + return; + } + const [handle] = [localFileHandle]; + unmountFileTrack(handle); + const uuid = scriptInfo?.uuid; + const fileName = handle?.name; + if (!uuid || !fileName) { + return; + } + setupWatchFile(uuid, fileName, handle); + return () => { + unmountFileTrack(handle); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [memoWatchFile]); + + // 检查是否有 uuid 或 file + const searchParamUrl = searchParams.get("url"); + const hasValidSourceParam = + !searchParamUrl && !!(searchParams.get("uuid") || searchParams.get("file") || skillInstallUuid); + + const urlHref = useMemo(() => { + if (searchParamUrl) { + try { + const idx = location.search.indexOf("url="); + const rawUrl = idx !== -1 ? location.search.slice(idx + 4) : searchParamUrl; + const urlObject = new URL(rawUrl); + if (urlObject.protocol && urlObject.hostname && urlObject.pathname) { + return rawUrl; + } + } catch { + // ignored + } + } + return ""; + }, [searchParamUrl]); + + const [fetchingState, setFetchingState] = useState({ + loadingStatus: "", + errorStatus: "", + }); + + const loadURLAsync = async (url: string) => { + const fetchValidScript = async () => { + const result = await fetchScriptBody(url, { + onProgress: (info: { receivedLength: number }) => { + setFetchingState((prev) => ({ + ...prev, + loadingStatus: t("downloading_status_text", { bytes: formatBytes(info.receivedLength) }), + })); + }, + }); + if (result.code && result.metadata) { + return { result, url } as const; + } + throw new Error(t("install_page_load_failed")); + }; + + try { + const { result, url } = await fetchValidScript(); + const { code, metadata } = result; + const isSkillScript = "skillScript" in result && result.skillScript === true; + + const uuid = uuidv4(); + const info = createScriptInfo(uuid, code, url, "user", metadata); + if (isSkillScript) { + info.skillScript = true; + } + const scriptData = [false, info]; + + await cacheInstance.set(`${CACHE_KEY_SCRIPT_INFO}${uuid}`, scriptData); + + setSearchParams(new URLSearchParams(`?uuid=${uuid}`), { replace: true }); + } catch (err: any) { + setFetchingState((prev) => ({ + ...prev, + loadingStatus: "", + errorStatus: `${err?.message || err}`, + })); + } + }; + + const handleUrlChangeAndFetch = (targetUrlHref: string) => { + setFetchingState((prev) => ({ + ...prev, + loadingStatus: t("install_page_please_wait"), + })); + loadURLAsync(targetUrlHref); + }; + + // 有 url 的话下载内容 + useEffect(() => { + if (urlHref) handleUrlChangeAndFetch(urlHref); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlHref]); + + return { + // 状态 + enable, + btnText, + scriptCode, + scriptInfo, + upsertScript, + diffCode, + oldScriptVersion, + isUpdate, + localFileHandle, + showBackgroundPrompt, + setShowBackgroundPrompt, + skillScriptMetadata, + watchFile, + metadataLive, + permissions, + descriptionParagraph, + antifeatures, + hasValidSourceParam, + urlHref, + fetchingState, + // 事件处理 + handleInstallBasic, + handleInstallCloseAfterInstall, + handleInstallNoMoreUpdates, + handleStatusChange, + handleCloseBasic, + handleCloseNoMoreUpdates, + setWatchFileClick, + // Skill 安装 + skillPreview, + skillInstallUuid, + handleSkillInstall, + handleSkillCancel, + // i18n + t, + }; +} + +export type InstallData = ReturnType; diff --git a/src/pages/install/utils.ts b/src/pages/install/utils.ts new file mode 100644 index 000000000..d18aa03f1 --- /dev/null +++ b/src/pages/install/utils.ts @@ -0,0 +1,149 @@ +import { parseMetadata } from "@App/pkg/utils/script"; +import { detectEncoding, bytesDecode } from "@App/pkg/utils/encoding"; +import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; +import { cacheInstance } from "@App/app/cache"; +import { CACHE_KEY_SCRIPT_INFO } from "@App/app/cache_key"; +import { timeoutExecution } from "@App/pkg/utils/timer"; +import type { SCMetadata } from "@App/app/repo/scripts"; + +export const cIdKey = `(cid_${Math.random()})`; + +export const backgroundPromptShownKey = "background_prompt_shown"; + +// Types +export interface PermissionItem { + label: string; + color?: string; + value: string[]; +} + +export type Permission = PermissionItem[]; + +export const closeWindow = (doBackwards: boolean) => { + if (doBackwards) { + history.go(-1); + } else { + window.close(); + } +}; + +export const fetchScriptBody = async (url: string, { onProgress }: { [key: string]: any }) => { + let origin; + try { + origin = new URL(url).origin; + } catch { + throw new Error(`Invalid url: ${url}`); + } + const response = await fetch(url, { + headers: { + "Cache-Control": "no-cache", + // 参考:加权 Accept-Encoding 值说明 + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding#weighted_accept-encoding_values + "Accept-Encoding": "br;q=1.0, gzip;q=0.8, *;q=0.1", + Origin: origin, + }, + referrer: origin + "/", + }); + + if (!response.ok) { + throw new Error(`Fetch failed with status ${response.status}`); + } + + if (!response.body || !response.headers) { + throw new Error("No response body or headers"); + } + const reader = response.body.getReader(); + + // 读取数据 + let receivedLength = 0; // 当前已接收的长度 + const chunks = []; // 已接收的二进制分片数组(用于组装正文) + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + chunks.push(value); + receivedLength += value.length; + onProgress?.({ receivedLength }); + } + + // 合并分片(chunks) + const chunksAll = new Uint8Array(receivedLength); + let position = 0; + for (const chunk of chunks) { + chunksAll.set(chunk, position); + position += chunk.length; + } + + // 检测编码:优先使用 Content-Type,回退到 chardet(仅检测前16KB) + const contentType = response.headers.get("content-type"); + const encode = detectEncoding(chunksAll, contentType); + + // 使用检测到的 charset 解码 + let code; + try { + code = bytesDecode(encode, chunksAll); + } catch (e: any) { + console.warn(`Failed to decode response with charset ${encode}: ${e.message}`); + // 回退到 UTF-8 + code = new TextDecoder("utf-8").decode(chunksAll); + } + + const metadata = parseMetadata(code); + // 如果不是 UserScript,检测是否为 SkillScript + if (!metadata) { + const skillScriptMeta = parseSkillScriptMetadata(code); + if (skillScriptMeta) { + return { code, metadata: {} as SCMetadata, skillScript: true }; + } + throw new Error("parse script info failed"); + } + + return { code, metadata }; +}; + +export const cleanupStaleInstallInfo = (uuid: string) => { + // 页面打开时不清除当前uuid,每30秒更新一次记录 + const f = () => { + cacheInstance.tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { + val = val || {}; + val[uuid] = Date.now(); + tx.set(val); + }); + }; + f(); + setInterval(f, 30_000); + + // 页面打开后清除旧记录 + const delay = Math.floor(5000 * Math.random()) + 10000; // 使用随机时间避免浏览器重启时大量Tabs同时执行清除 + timeoutExecution( + `${cIdKey}cleanupStaleInstallInfo`, + () => { + cacheInstance + .tx(`scriptInfoKeeps`, (val: Record | undefined, tx) => { + const now = Date.now(); + const keeps = new Set(); + const out: Record = {}; + for (const [k, ts] of Object.entries(val ?? {})) { + if (ts > 0 && now - ts < 60_000) { + keeps.add(`${CACHE_KEY_SCRIPT_INFO}${k}`); + out[k] = ts; + } + } + tx.set(out); + return keeps; + }) + .then(async (keeps) => { + const list = await cacheInstance.list(); + const filtered = list.filter((key) => key.startsWith(CACHE_KEY_SCRIPT_INFO) && !keeps.has(key)); + if (filtered.length) { + // 清理缓存 + cacheInstance.dels(filtered); + } + }); + }, + delay + ); +}; diff --git a/src/pages/options/routes/Agent.tsx b/src/pages/options/routes/Agent.tsx new file mode 100644 index 000000000..3942429a4 --- /dev/null +++ b/src/pages/options/routes/Agent.tsx @@ -0,0 +1,25 @@ +import { Route, Routes } from "react-router-dom"; +import AgentProvider from "./AgentProvider"; +import AgentChat from "./AgentChat"; +import AgentMcp from "./AgentMcp"; +import AgentOPFS from "./AgentOPFS"; +import AgentSkills from "./AgentSkills"; +import AgentTasks from "./AgentTasks"; +import AgentSettings from "./AgentSettings"; + +function Agent() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} + +export default Agent; diff --git a/src/pages/options/routes/AgentChat/AskUserBlock.tsx b/src/pages/options/routes/AgentChat/AskUserBlock.tsx new file mode 100644 index 000000000..98ced48a2 --- /dev/null +++ b/src/pages/options/routes/AgentChat/AskUserBlock.tsx @@ -0,0 +1,182 @@ +import { useState, useRef, useEffect } from "react"; +import { IconSend, IconCheckCircleFill, IconCheck } from "@arco-design/web-react/icon"; + +export default function AskUserBlock({ + id, + question, + options, + multiple, + onRespond, +}: { + id: string; + question: string; + options?: string[]; + multiple?: boolean; + onRespond: (id: string, answer: string) => void; +}) { + const [answer, setAnswer] = useState(""); + const [selectedOptions, setSelectedOptions] = useState([]); + const [submitted, setSubmitted] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleSubmit = () => { + if (submitted) return; + if (!answer.trim()) return; + setSubmitted(true); + onRespond(id, answer.trim()); + }; + + // 单选:点击选项直接提交 + const handleSingleSelect = (value: string) => { + if (submitted) return; + setSelectedOptions([value]); + setAnswer(value); + setSubmitted(true); + onRespond(id, value); + }; + + // 多选:切换选中状态 + const handleMultiToggle = (value: string) => { + if (submitted) return; + setSelectedOptions((prev) => (prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value])); + }; + + // 多选:确认提交 + const handleMultiSubmit = () => { + if (submitted || selectedOptions.length === 0) return; + const result = JSON.stringify(selectedOptions); + setAnswer(result); + setSubmitted(true); + onRespond(id, result); + }; + + const displayAnswer = (() => { + if (!answer) return ""; + // 多选时尝试解析为数组展示 + if (multiple) { + try { + const arr = JSON.parse(answer); + if (Array.isArray(arr)) return arr.join(", "); + } catch { + // 用户自行输入的文本 + } + } + return answer; + })(); + + const hasOptions = options && options.length > 0; + + // 已提交:紧凑的完成状态 + if (submitted) { + return ( +
    +
    + +
    +
    {question}
    +
    {displayAnswer}
    +
    +
    +
    + ); + } + + return ( +
    +
    + {/* 顶部渐变条 */} +
    + +
    + {/* 问题 */} +
    +
    + ? +
    +
    + {question} +
    +
    + + {/* 选项区域 */} + {hasOptions && ( +
    + {options.map((opt) => { + const isSelected = selectedOptions.includes(opt); + return ( + + ); + })} +
    + )} + + {/* 多选确认按钮 */} + {hasOptions && multiple && selectedOptions.length > 0 && ( +
    + +
    + )} + + {/* 文本输入 */} +
    + setAnswer(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSubmit()} + placeholder={hasOptions ? "Or type a custom response..." : "Type your response..."} + className="tw-flex-1 tw-bg-transparent tw-border-none tw-outline-none tw-text-sm tw-text-[var(--color-text-1)] placeholder:tw-text-[var(--color-text-4)] tw-min-w-0" + /> + +
    +
    +
    +
    + ); +} diff --git a/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx b/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx new file mode 100644 index 000000000..5d8080073 --- /dev/null +++ b/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx @@ -0,0 +1,137 @@ +import { useState, useEffect, useCallback } from "react"; +import { IconDownload, IconEye } from "@arco-design/web-react/icon"; +import type { Attachment, AudioBlock } from "@App/app/service/agent/core/types"; +import { AgentChatRepo } from "@App/app/repo/agent_chat"; + +const repo = new AgentChatRepo(); + +// 图片附件组件:从 OPFS 懒加载并展示 +export function AttachmentImage({ attachment }: { attachment: Attachment }) { + const [blobUrl, setBlobUrl] = useState(null); + const [preview, setPreview] = useState(false); + + useEffect(() => { + let revoked = false; + repo.getAttachment(attachment.id).then((blob) => { + if (blob && !revoked) { + setBlobUrl(URL.createObjectURL(blob)); + } + }); + return () => { + revoked = true; + if (blobUrl) URL.revokeObjectURL(blobUrl); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [attachment.id]); + + if (!blobUrl) { + return ( +
    + {"Loading..."} +
    + ); + } + + return ( + <> +
    setPreview(true)}> + {attachment.name} +
    + +
    +
    + {/* 全屏预览 */} + {preview && ( +
    setPreview(false)} + > + {attachment.name} e.stopPropagation()} + /> +
    + )} + + ); +} + +// 文件附件组件:显示文件信息和下载按钮 +export function AttachmentFile({ attachment }: { attachment: Attachment }) { + const handleDownload = useCallback(async () => { + const blob = await repo.getAttachment(attachment.id); + if (!blob) return; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = attachment.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, [attachment.id, attachment.name]); + + const sizeText = attachment.size + ? attachment.size < 1024 + ? `${attachment.size} B` + : attachment.size < 1024 * 1024 + ? `${(attachment.size / 1024).toFixed(1)} KB` + : `${(attachment.size / (1024 * 1024)).toFixed(1)} MB` + : ""; + + return ( +
    + +
    + {attachment.name} + {sizeText && {sizeText}} +
    +
    + ); +} + +// 音频附件组件:audio 播放器 +export function AttachmentAudio({ block }: { block: AudioBlock }) { + const [blobUrl, setBlobUrl] = useState(null); + + useEffect(() => { + let revoked = false; + repo.getAttachment(block.attachmentId).then((blob) => { + if (blob && !revoked) { + setBlobUrl(URL.createObjectURL(blob)); + } + }); + return () => { + revoked = true; + if (blobUrl) URL.revokeObjectURL(blobUrl); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [block.attachmentId]); + + if (!blobUrl) { + return ( +
    + {"Loading audio..."} +
    + ); + } + + return ( +
    + {block.name && {block.name}} +
    + ); +} diff --git a/src/pages/options/routes/AgentChat/ChatArea.tsx b/src/pages/options/routes/AgentChat/ChatArea.tsx new file mode 100644 index 000000000..87247abbc --- /dev/null +++ b/src/pages/options/routes/AgentChat/ChatArea.tsx @@ -0,0 +1,797 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Message as ArcoMessage } from "@arco-design/web-react"; +import { IconRobot } from "@arco-design/web-react/icon"; +import type { AgentModelConfig, SkillSummary, ContentBlock, MessageContent } from "@App/app/service/agent/core/types"; +import { AgentChatRepo } from "@App/app/repo/agent_chat"; +import type { ChatMessage, ChatStreamEvent } from "@App/app/service/agent/core/types"; +import { getTextContent } from "@App/app/service/agent/core/content_utils"; +import { UserMessageItem, AssistantMessageGroup } from "./MessageItem"; +import ChatInput from "./ChatInput"; +import { useMessages, useStreamingChat, useConversationTasks, deleteMessages, clearMessages } from "./hooks"; +import AskUserBlock from "./AskUserBlock"; +import TaskListBlock from "./TaskListBlock"; +import type { SubAgentState } from "./SubAgentBlock"; +import { + mergeToolResults, + groupMessages, + computeRegenerateAction, + computeEditAction, + computeUserRegenerateAction, + findNextAssistantGroupIndex, + type MessageGroup, +} from "./chat_utils"; + +function genId(): string { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 8); +} + +const chatRepo = new AgentChatRepo(); + +// 欢迎界面 +function WelcomeScreen({ hasConversation }: { hasConversation: boolean }) { + const { t } = useTranslation(); + + return ( +
    +
    + +
    +

    + {hasConversation ? t("agent_chat_input_placeholder") : t("agent_chat_no_conversations")} +

    +

    + {hasConversation + ? t("agent_chat_welcome_hint") || "Ask me anything about your scripts" + : t("agent_chat_welcome_start") || "Create a conversation to get started"} +

    +
    + ); +} + +export default function ChatArea({ + conversationId, + models, + modelsLoaded, + selectedModelId, + onModelChange, + onConversationTitleChange, + skills, + selectedSkills, + onSkillsChange, + enableTools, + onEnableToolsChange, + runningIds, + backgroundEnabled, + onBackgroundEnabledChange, +}: { + conversationId: string; + models: AgentModelConfig[]; + modelsLoaded?: boolean; + selectedModelId: string; + onModelChange: (id: string) => void; + onConversationTitleChange?: () => void; + skills?: SkillSummary[]; + selectedSkills?: "auto" | string[]; + onSkillsChange?: (skills: "auto" | string[]) => void; + enableTools?: boolean; + onEnableToolsChange?: (enabled: boolean) => void; + runningIds?: Set; + backgroundEnabled?: boolean; + onBackgroundEnabledChange?: (enabled: boolean) => void; +}) { + const { t } = useTranslation(); + const { messages, setMessages, loadMessages } = useMessages(conversationId); + const { + isStreaming, + setIsStreaming, + sendMessage, + stopGeneration, + askUserPending, + respondToAskUser, + attachToConversation, + } = useStreamingChat(); + const { tasks, setTasks, handleTaskUpdate, loadTasks } = useConversationTasks(conversationId); + const messagesEndRef = useRef(null); + const streamingMsgRef = useRef(null); + // 计时相关 + const sendStartTimeRef = useRef(0); + const firstTokenRecordedRef = useRef(false); + const firstTokenMsRef = useRef(undefined); + + // 待处理的用户消息(LLM 运行中排队) + const pendingMessageRef = useRef<{ content: MessageContent; messageId: string } | null>(null); + const [pendingMessageId, setPendingMessageId] = useState(null); + + // 会话切换时清除待处理消息 + useEffect(() => { + pendingMessageRef.current = null; + setPendingMessageId(null); + }, [conversationId]); + + // 自动滚动到底部 + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, scrollToBottom]); + + // 流式期间累积的非文本 blocks(content_block_complete 事件) + const pendingBlocksRef = useRef([]); + // 子代理状态跟踪(流式期间按 agentId 维护) + const subAgentsRef = useRef>(new Map()); + // 重试倒计时定时器 + const retryTimerRef = useRef | null>(null); + + // 清除重试倒计时 + const clearRetryTimer = useCallback(() => { + if (retryTimerRef.current) { + clearInterval(retryTimerRef.current); + retryTimerRef.current = null; + } + }, []); + + // 创建流式事件回调(提取公共逻辑) + const createStreamCallback = () => { + pendingBlocksRef.current = []; + subAgentsRef.current = new Map(); + clearRetryTimer(); + return (event: ChatStreamEvent) => { + // 不依赖流式消息的事件优先处理 + if (event.type === "task_update") { + handleTaskUpdate(event); + return; + } + if (event.type === "compact_done") { + loadMessages(); + return; + } + + const msg = streamingMsgRef.current; + if (!msg) return; + + switch (event.type) { + case "content_delta": + if (!firstTokenRecordedRef.current) { + firstTokenRecordedRef.current = true; + firstTokenMsRef.current = Date.now() - sendStartTimeRef.current; + } + // 重试成功后清除重试错误提示和倒计时 + if (msg.error) { + clearRetryTimer(); + msg.error = undefined; + } + // streaming 期间 content 始终为 string + if (typeof msg.content === "string") { + 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.error) { + clearRetryTimer(); + msg.error = undefined; + } + 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 "tool_call_complete": { + const tc = msg.toolCalls?.find((t) => t.id === event.id); + if (tc) { + tc.status = "completed"; + tc.result = event.result; + tc.attachments = event.attachments; + } + break; + } + case "ask_user": + // ask_user 事件由 hook 层处理 + break; + case "sub_agent_event": { + // 处理子代理事件:构建完整的子代理执行状态 + const { agentId, description, subAgentType, event: innerEvent } = event; + let sa = subAgentsRef.current.get(agentId); + if (!sa) { + sa = { + agentId, + description, + subAgentType, + completedMessages: [], + currentContent: "", + currentThinking: "", + currentToolCalls: [], + isRunning: true, + }; + subAgentsRef.current.set(agentId, sa); + } + // 分派内部事件 + switch (innerEvent.type) { + case "content_delta": + sa.currentContent += innerEvent.delta; + break; + case "thinking_delta": + sa.currentThinking += innerEvent.delta; + break; + case "tool_call_start": + sa.currentToolCalls.push({ ...innerEvent.toolCall, status: "running" }); + break; + case "tool_call_delta": + if (sa.currentToolCalls.length) { + const lastTc = sa.currentToolCalls[sa.currentToolCalls.length - 1]; + lastTc.arguments += innerEvent.delta; + } + break; + case "tool_call_complete": { + const tc = sa.currentToolCalls.find((t) => t.id === innerEvent.id); + if (tc) { + tc.status = "completed"; + tc.result = innerEvent.result; + tc.attachments = innerEvent.attachments; + } + break; + } + case "new_message": + // 当前轮次完成,归档到 completedMessages + if (sa.currentContent || sa.currentThinking || sa.currentToolCalls.length > 0) { + sa.completedMessages.push({ + content: sa.currentContent, + thinking: sa.currentThinking || undefined, + toolCalls: [...sa.currentToolCalls], + }); + } + sa.currentContent = ""; + sa.currentThinking = ""; + sa.currentToolCalls = []; + break; + case "retry": + // 显示重试提示 + sa.retryInfo = { + attempt: innerEvent.attempt, + maxRetries: innerEvent.maxRetries, + error: innerEvent.error, + }; + break; + case "done": + // 收集 usage + if (innerEvent.usage) { + if (!sa.usage) sa.usage = { inputTokens: 0, outputTokens: 0 }; + sa.usage.inputTokens += innerEvent.usage.inputTokens; + sa.usage.outputTokens += innerEvent.usage.outputTokens; + sa.usage.cacheCreationInputTokens = + (sa.usage.cacheCreationInputTokens || 0) + (innerEvent.usage.cacheCreationInputTokens || 0); + sa.usage.cacheReadInputTokens = + (sa.usage.cacheReadInputTokens || 0) + (innerEvent.usage.cacheReadInputTokens || 0); + } + // falls through + case "error": + // 最后一轮归档 + sa.retryInfo = undefined; // 清除重试提示 + if (sa.currentContent || sa.currentThinking || sa.currentToolCalls.length > 0) { + sa.completedMessages.push({ + content: sa.currentContent, + thinking: sa.currentThinking || undefined, + toolCalls: [...sa.currentToolCalls], + }); + sa.currentContent = ""; + sa.currentThinking = ""; + sa.currentToolCalls = []; + } + sa.isRunning = false; + break; + } + break; + } + case "content_block_start": + // 非文本 block 开始,暂不处理(等 complete 时处理) + break; + case "content_block_complete": + // 非文本 block 完成,加入 pending 列表 + pendingBlocksRef.current.push(event.block); + break; + case "new_message": { + // 在开始新消息前,合并 pending blocks 到当前消息 + if (pendingBlocksRef.current.length > 0) { + const textContent = typeof msg.content === "string" ? msg.content : ""; + const blocks: ContentBlock[] = []; + if (textContent) blocks.push({ type: "text", text: textContent }); + blocks.push(...pendingBlocksRef.current); + msg.content = blocks; + pendingBlocksRef.current = []; + } + + const newMsg: ChatMessage = { + id: genId(), + conversationId, + role: "assistant", + content: "", + modelId: selectedModelId, + createtime: Date.now(), + }; + streamingMsgRef.current = newMsg; + setMessages((prev) => [...prev, newMsg]); + return; + } + case "sync": + // 重连快照:从快照重建流式消息状态 + if (event.streamingMessage) { + msg.content = event.streamingMessage.content; + if (event.streamingMessage.thinking) { + msg.thinking = { content: event.streamingMessage.thinking }; + } + if (event.streamingMessage.toolCalls.length > 0) { + msg.toolCalls = event.streamingMessage.toolCalls; + } + } + if (event.tasks.length > 0) { + setTasks(event.tasks); + } + break; + case "retry": { + clearRetryTimer(); + const retryDeadline = Date.now() + event.delayMs; + const updateRetryMsg = () => { + const remaining = Math.max(0, Math.ceil((retryDeadline - Date.now()) / 1000)); + msg.error = `${event.error}\n${t("agent_chat_retrying", { attempt: event.attempt, max: event.maxRetries })} (${remaining}s)`; + setMessages((prev) => { + const updated = [...prev]; + const idx = updated.findIndex((m) => m.id === msg.id); + if (idx >= 0) updated[idx] = { ...msg }; + return updated; + }); + if (remaining <= 0) clearRetryTimer(); + }; + updateRetryMsg(); + retryTimerRef.current = setInterval(updateRetryMsg, 1000); + break; + } + case "system_warning": + msg.warning = event.message; + break; + case "error": + msg.error = event.message; + break; + case "done": + if (event.usage) msg.usage = event.usage; + if (event.durationMs != null) msg.durationMs = event.durationMs; + if (firstTokenMsRef.current != null) msg.firstTokenMs = firstTokenMsRef.current; + // 合并 pending blocks 到最终消息 + if (pendingBlocksRef.current.length > 0) { + const textContent = typeof msg.content === "string" ? msg.content : ""; + const blocks: ContentBlock[] = []; + if (textContent) blocks.push({ type: "text", text: textContent }); + blocks.push(...pendingBlocksRef.current); + msg.content = blocks; + pendingBlocksRef.current = []; + } + break; + } + + setMessages((prev) => { + const updated = [...prev]; + const idx = updated.findIndex((m) => m.id === msg.id); + if (idx >= 0) { + updated[idx] = { ...msg }; + } + return updated; + }); + }; + }; + + // 处理排队的用户消息 + const processPendingMessage = async () => { + const pending = pendingMessageRef.current; + if (!pending) return; + pendingMessageRef.current = null; + setPendingMessageId(null); + const freshMsgs = await chatRepo.getMessages(conversationId); + startStreamingRef.current(freshMsgs, pending.content); + }; + + const createDoneCallback = () => { + return async () => { + clearRetryTimer(); + streamingMsgRef.current = null; + if (pendingMessageRef.current) { + // 有排队消息:跳过 loadMessages 避免闪烁,直接处理 + onConversationTitleChange?.(); + await processPendingMessage(); + } else { + await loadMessages(); + onConversationTitleChange?.(); + } + }; + }; + + // 初始化流式请求的公共逻辑 + const startStreaming = (baseMessages: ChatMessage[], content: MessageContent, skipUserMessage?: boolean) => { + sendStartTimeRef.current = Date.now(); + firstTokenRecordedRef.current = false; + firstTokenMsRef.current = undefined; + + const newMessages = [...baseMessages]; + if (!skipUserMessage) { + newMessages.push({ + id: genId(), + conversationId, + role: "user", + content, + createtime: Date.now(), + }); + } + + const assistantMsg: ChatMessage = { + id: genId(), + conversationId, + role: "assistant", + content: "", + modelId: selectedModelId, + createtime: Date.now(), + }; + streamingMsgRef.current = assistantMsg; + newMessages.push(assistantMsg); + + setMessages(newMessages); + sendMessage( + conversationId, + content, + createStreamCallback(), + createDoneCallback(), + selectedModelId, + skipUserMessage, + enableTools, + { background: backgroundEnabled } + ); + }; + + // 用 ref 保存 startStreaming 的最新引用,避免 useCallback 闭包陈旧 + const startStreamingRef = useRef(startStreaming); + startStreamingRef.current = startStreaming; + + // 自动附加到后台运行中的会话 + useEffect(() => { + if (!conversationId || isStreaming) return; + if (!runningIds?.has(conversationId)) return; + + // 创建一个临时的 assistant 消息用于显示流式内容 + const assistantMsg: ChatMessage = { + id: genId(), + conversationId, + role: "assistant", + content: "", + modelId: selectedModelId, + createtime: Date.now(), + }; + streamingMsgRef.current = assistantMsg; + setIsStreaming(true); + + // 先加载已持久化的消息 + loadMessages().then(() => { + setMessages((prev) => [...prev, assistantMsg]); + + attachToConversation(conversationId, createStreamCallback(), createDoneCallback()); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversationId, runningIds]); + + // 发送消息(支持附件文件或已有消息列表用于重新回答) + const handleSend = async (content: MessageContent, files?: Map) => { + if (!conversationId || !selectedModelId) return; + + // 处理 /new 命令:清空对话上下文及任务(流式中不允许) + if (typeof content === "string" && content.trim() === "/new") { + if (isStreaming) return; + await clearMessages(conversationId); + setMessages([]); + loadTasks(); + return; + } + + // 处理 /compact 命令:压缩对话历史(流式中不允许) + if (typeof content === "string" && content.trim().startsWith("/compact")) { + if (isStreaming) return; + const instruction = content.trim().slice("/compact".length).trim(); + sendMessage( + conversationId, + "", + (event) => { + if (event.type === "compact_done") { + // compact 完成后刷新消息列表 + loadMessages(); + } + }, + () => {}, + selectedModelId, + undefined, + undefined, + { + compact: true, + compactInstruction: instruction || undefined, + } + ); + return; + } + + // 保存附件到 OPFS + if (files && files.size > 0) { + for (const [id, file] of files) { + await chatRepo.saveAttachment(id, file); + } + } + + // LLM 运行中:排队等待空闲时处理 + if (isStreaming) { + const msgId = genId(); + pendingMessageRef.current = { content, messageId: msgId }; + setPendingMessageId(msgId); + setMessages((prev) => [ + ...prev, + { + id: msgId, + conversationId, + role: "user" as const, + content, + createtime: Date.now(), + }, + ]); + return; + } + + startStreaming(messages, content); + }; + + // 复制消息组的文本内容到剪贴板 + const handleCopy = useCallback( + (groupMessages: ChatMessage[]) => { + const text = groupMessages + .map((m) => getTextContent(m.content)) + .filter(Boolean) + .join("\n\n"); + navigator.clipboard.writeText(text).then(() => { + ArcoMessage.success(t("agent_chat_copy_success")); + }); + }, + [t] + ); + + // 清理任务(重试/编辑时调用) + const clearTasks = useCallback(async () => { + await chatRepo.saveTasks(conversationId, []); + setTasks([]); + }, [conversationId, setTasks]); + + // 重新回答:删除当前 assistant 消息组,用上一条用户消息重新请求 + const handleRegenerate = useCallback( + async (groups: MessageGroup[], groupIndex: number) => { + if (isStreaming) return; + + const action = computeRegenerateAction(groups, groupIndex, messages); + if (!action) return; + + await deleteMessages(conversationId, action.idsToDelete); + await clearTasks(); + setMessages(action.remainingMessages); + + // 通过 ref 调用最新的 startStreaming,避免闭包陈旧 + startStreamingRef.current(action.remainingMessages, action.userContent); + }, + [conversationId, isStreaming, messages, setMessages, clearTasks] + ); + + // 重新生成用户消息的回复:保留用户消息,只删除后续回复 + const handleRegenerateUserMessage = useCallback( + async (messageId: string) => { + if (isStreaming) return; + + const action = computeUserRegenerateAction(messageId, messages); + if (!action) return; + + if (action.idsToDelete.length > 0) { + await deleteMessages(conversationId, action.idsToDelete); + } + await clearTasks(); + + setMessages(action.remainingMessages); + + // skipUserMessage=true:用户消息已在 remainingMessages 中,不需要重新创建 + startStreamingRef.current(action.remainingMessages, action.userContent, action.skipUserMessage); + }, + [conversationId, isStreaming, messages, setMessages, clearTasks] + ); + + // 删除整轮对话(用户消息 + assistant 消息组) + const handleDeleteRound = useCallback( + async (groups: MessageGroup[], groupIndex: number) => { + if (isStreaming) return; + + const group = groups[groupIndex]; + if (group.type !== "assistant") return; + + const idsToDelete: string[] = group.messages.map((m) => m.id); + + // 找到前面的用户消息 + for (let i = groupIndex - 1; i >= 0; i--) { + if (groups[i].type === "user") { + idsToDelete.push((groups[i] as { type: "user"; message: ChatMessage }).message.id); + break; + } + } + + // 从持久化存储中删除(这里要删除原始消息,包括 tool 角色消息) + const allToolCallIds = group.messages.flatMap((m) => m.toolCalls?.map((tc) => tc.id) || []); + const originalToolMsgIds = messages + .filter((m) => m.role === "tool" && m.toolCallId && allToolCallIds.includes(m.toolCallId)) + .map((m) => m.id); + idsToDelete.push(...originalToolMsgIds); + + await deleteMessages(conversationId, idsToDelete); + loadMessages(); + }, + [conversationId, isStreaming, messages, loadMessages] + ); + + // 编辑用户消息并重新发送:删除该消息及其后的所有消息 + const handleEditMessage = useCallback( + async (messageId: string, content: MessageContent, files?: Map) => { + if (isStreaming) return; + + const action = computeEditAction(messageId, messages); + if (!action) return; + + // 保存新附件到 OPFS + if (files && files.size > 0) { + for (const [id, file] of files) { + await chatRepo.saveAttachment(id, file); + } + } + + await deleteMessages(conversationId, action.idsToDelete); + await clearTasks(); + setMessages(action.remainingMessages); + + // 通过 ref 调用最新的 startStreaming + startStreamingRef.current(action.remainingMessages, content); + }, + [conversationId, isStreaming, messages, setMessages, clearTasks] + ); + + // 停止生成:重置流式状态,将未完成的 tool call 标记为 error,并重新加载持久化消息 + const handleStop = useCallback(async () => { + clearRetryTimer(); + stopGeneration(); + streamingMsgRef.current = null; + // 将仍处于 running 状态的 tool call 标记为 error,避免 spinner 一直转 + setMessages((prev) => { + const needsUpdate = prev.some((m) => m.toolCalls?.some((tc) => tc.status === "running")); + if (!needsUpdate) return prev; + return prev.map((m) => { + if (!m.toolCalls?.some((tc) => tc.status === "running")) return m; + return { + ...m, + toolCalls: m.toolCalls!.map((tc) => (tc.status === "running" ? { ...tc, status: "error" as const } : tc)), + }; + }); + }); + // 有待处理消息时,停止后自动发送 + if (pendingMessageRef.current) { + processPendingMessage(); + } else { + // 重新加载持久化消息,恢复到后端实际保存的状态 + loadMessages(); + } + }, [clearRetryTimer, stopGeneration, setMessages, loadMessages, conversationId]); + + // 取消排队的用户消息 + const handleCancelPending = useCallback(() => { + const pending = pendingMessageRef.current; + if (!pending) return; + const msgId = pending.messageId; + pendingMessageRef.current = null; + setPendingMessageId(null); + setMessages((prev) => prev.filter((m) => m.id !== msgId)); + }, [setMessages]); + + // 兜底:连接断开但 done 回调未触发时,处理排队消息 + const prevStreamingRef = useRef(false); + useEffect(() => { + if (prevStreamingRef.current && !isStreaming && pendingMessageRef.current) { + processPendingMessage(); + } + prevStreamingRef.current = isStreaming; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isStreaming]); + + // 只在模型加载完成后才判断是否无模型,避免加载中闪现提示 + const noModel = modelsLoaded === true && models.length === 0; + const showWelcome = !conversationId || (messages.length === 0 && !isStreaming); + const mergedMessages = mergeToolResults(messages); + const messageGroups = groupMessages(mergedMessages); + + return ( +
    + {/* 消息列表 */} +
    +
    + {showWelcome ? ( + + ) : ( + messageGroups.map((group, groupIndex) => + group.type === "user" ? ( + handleEditMessage(group.message.id, content, files)} + onRegenerate={ + findNextAssistantGroupIndex(messageGroups, groupIndex) != null || + groupIndex === messageGroups.length - 1 + ? () => handleRegenerateUserMessage(group.message.id) + : undefined + } + onCancel={pendingMessageId === group.message.id ? handleCancelPending : undefined} + /> + ) : ( + 0 ? subAgentsRef.current : undefined} + onCopy={() => handleCopy(group.messages)} + onRegenerate={() => handleRegenerate(messageGroups, groupIndex)} + onDelete={() => handleDeleteRound(messageGroups, groupIndex)} + /> + ) + ) + )} + {askUserPending && ( + + )} + {tasks.length > 0 && } +
    +
    +
    + + {/* 输入区域 */} + + {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..1d5461027 --- /dev/null +++ b/src/pages/options/routes/AgentChat/ChatInput.tsx @@ -0,0 +1,587 @@ +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { Select, Tooltip, Message as ArcoMessage } from "@arco-design/web-react"; +import { + IconSend, + IconPause, + IconImage, + IconClose, + IconEye, + IconTool, + IconFile, + IconPlayCircle, + IconThunderbolt, +} from "@arco-design/web-react/icon"; +import { useTranslation } from "react-i18next"; +import type { AgentModelConfig, SkillSummary, MessageContent, ContentBlock } from "@App/app/service/agent/core/types"; +import { groupModelsByProvider, supportsVision, supportsImageOutput } from "./model_utils"; +import ProviderIcon from "./ProviderIcon"; + +// 斜杠命令弹出菜单 +function SlashCommandMenu({ + items, + activeIndex, + onSelect, +}: { + items: SkillSummary[]; + activeIndex: number; + onSelect: (skill: SkillSummary) => void; +}) { + const listRef = useRef(null); + + // 保持选中项在视口内 + useEffect(() => { + const container = listRef.current; + if (!container) return; + const active = container.children[activeIndex] as HTMLElement | undefined; + active?.scrollIntoView({ block: "nearest" }); + }, [activeIndex]); + + if (items.length === 0) return null; + + return ( +
    + {items.map((skill, i) => ( +
    { + e.preventDefault(); // 阻止 textarea 失焦 + onSelect(skill); + }} + className={`tw-flex tw-flex-col tw-gap-0.5 tw-px-3 tw-py-2 tw-cursor-pointer tw-transition-colors ${ + i === activeIndex + ? "tw-bg-[rgb(var(--arcoblue-1))] dark:tw-bg-[rgba(var(--arcoblue-6),0.1)]" + : "hover:tw-bg-[var(--color-fill-1)]" + }`} + > + + + /{skill.name} + + {skill.description && ( + + {skill.description} + + )} +
    + ))} +
    + ); +} + +function ModelSelect({ + models, + selectedModelId, + onModelChange, +}: { + models: AgentModelConfig[]; + selectedModelId: string; + onModelChange: (id: string) => void; +}) { + const groups = useMemo(() => groupModelsByProvider(models), [models]); + const hasMultipleGroups = groups.length > 1; + + const renderOption = (m: AgentModelConfig, providerKey: string) => ( + + + {!hasMultipleGroups && } + {m.name} + {supportsVision(m) && } + {supportsImageOutput(m) && } + + + ); + + // 找到当前选中模型的供应商用于 renderFormat + const selectedProviderKey = useMemo(() => { + for (const g of groups) { + if (g.models.some((m) => m.id === selectedModelId)) { + return g.provider.key; + } + } + return "other"; + }, [groups, selectedModelId]); + + return ( + + ); +} + +type PendingAttachment = { + id: string; + file: File; + previewUrl: string; +}; + +export default function ChatInput({ + models, + selectedModelId, + onModelChange, + onSend, + onStop, + isStreaming, + disabled, + skills, + selectedSkills, + onSkillsChange, + enableTools, + onEnableToolsChange, + backgroundEnabled, + onBackgroundEnabledChange, + hasPendingMessage, +}: { + models: AgentModelConfig[]; + selectedModelId: string; + onModelChange: (id: string) => void; + onSend: (content: MessageContent, files?: Map) => void; + onStop: () => void; + isStreaming: boolean; + disabled?: boolean; + skills?: SkillSummary[]; + selectedSkills?: "auto" | string[]; + onSkillsChange?: (skills: "auto" | string[]) => void; + enableTools?: boolean; + onEnableToolsChange?: (enabled: boolean) => void; + backgroundEnabled?: boolean; + onBackgroundEnabledChange?: (enabled: boolean) => void; + hasPendingMessage?: boolean; +}) { + const { t } = useTranslation(); + const [input, setInput] = useState(""); + const [attachments, setAttachments] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [slashActiveIndex, setSlashActiveIndex] = useState(0); + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + + // 斜杠命令过滤 + const slashQuery = useMemo(() => { + const match = input.match(/^\/(\S*)$/); + return match ? match[1].toLowerCase() : null; + }, [input]); + + const filteredSkills = useMemo(() => { + if (slashQuery === null || !skills || skills.length === 0) return []; + if (slashQuery === "") return skills; + return skills.filter( + (s) => s.name.toLowerCase().includes(slashQuery) || s.description.toLowerCase().includes(slashQuery) + ); + }, [slashQuery, skills]); + + const showSlashMenu = filteredSkills.length > 0; + + // 重置选中索引 + useEffect(() => { + setSlashActiveIndex(0); + }, [filteredSkills.length]); + + // 自动调整高度 + useEffect(() => { + const el = textareaRef.current; + if (el) { + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 200) + "px"; + } + }, [input]); + + // 清理 objectURLs + useEffect(() => { + return () => { + attachments.forEach((a) => { + if (a.previewUrl) URL.revokeObjectURL(a.previewUrl); + }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const addFiles = useCallback((files: File[]) => { + if (files.length === 0) return; + const newAttachments = files.map((file) => { + // 从文件名提取扩展名,fallback 到 MIME 子类型 + const ext = file.name.includes(".") ? file.name.split(".").pop() : file.type.split("/")[1] || "bin"; + return { + id: `att_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`, + file, + previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : "", + }; + }); + setAttachments((prev) => [...prev, ...newAttachments]); + }, []); + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => { + const att = prev.find((a) => a.id === id); + if (att?.previewUrl) URL.revokeObjectURL(att.previewUrl); + return prev.filter((a) => a.id !== id); + }); + }, []); + + const handleSend = () => { + const trimmed = input.trim(); + if ((!trimmed && attachments.length === 0) || disabled || hasPendingMessage) return; + + if (attachments.length > 0) { + // 构建 ContentBlock[] 和 files Map + const blocks: ContentBlock[] = []; + const files = new Map(); + + if (trimmed) { + blocks.push({ type: "text", text: trimmed }); + } + for (const att of attachments) { + const mime = att.file.type; + if (mime.startsWith("image/")) { + blocks.push({ type: "image", attachmentId: att.id, mimeType: mime, name: att.file.name }); + } else if (mime.startsWith("audio/")) { + blocks.push({ type: "audio", attachmentId: att.id, mimeType: mime, name: att.file.name }); + } else { + blocks.push({ type: "file", attachmentId: att.id, mimeType: mime, name: att.file.name, size: att.file.size }); + } + files.set(att.id, att.file); + } + + onSend(blocks, files); + // 清理(不 revoke,发送后由调用方负责) + setAttachments([]); + } else { + onSend(trimmed); + } + setInput(""); + }; + + const handleSlashSelect = useCallback((skill: SkillSummary) => { + setInput(`/${skill.name} `); + textareaRef.current?.focus(); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + // 忽略输入法组合状态中的回车(如中文输入法确认候选词) + if (e.nativeEvent.isComposing) return; + + // 斜杠命令菜单键盘导航 + if (showSlashMenu) { + if (e.key === "ArrowUp") { + e.preventDefault(); + setSlashActiveIndex((prev) => (prev <= 0 ? filteredSkills.length - 1 : prev - 1)); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setSlashActiveIndex((prev) => (prev >= filteredSkills.length - 1 ? 0 : prev + 1)); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + handleSlashSelect(filteredSkills[slashActiveIndex]); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setInput(""); + return; + } + } + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + if (items[i].kind === "file") { + const file = items[i].getAsFile(); + if (file) files.push(file); + } + } + if (files.length > 0) { + e.preventDefault(); + addFiles(files); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const files = Array.from(e.dataTransfer.files); + addFiles(files); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + addFiles(files); + // reset input value so the same file can be selected again + e.target.value = ""; + }; + + const canSend = (input.trim() || attachments.length > 0) && !disabled && !hasPendingMessage; + + return ( +
    +
    +
    + {/* 斜杠命令弹出菜单 */} + {showSlashMenu && ( + + )} + +
    + {/* 附件预览条 */} + {attachments.length > 0 && ( +
    + {attachments.map((att) => ( +
    + {att.previewUrl ? ( + {att.file.name} + ) : ( +
    + {att.file.type.startsWith("audio/") ? ( + + ) : ( + + )} + + {att.file.name.length > 8 + ? att.file.name.slice(0, 5) + "..." + (att.file.name.split(".").pop() || "") + : att.file.name} + +
    + )} + +
    + ))} +
    + )} + + {/* 输入区域 */} +
    +