diff --git a/.github/ISSUE_TEMPLATE/bug_report_en.yaml b/.github/ISSUE_TEMPLATE/bug_report_en.yaml index 043ef521c..7b62702d6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report_en.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report_en.yaml @@ -14,7 +14,9 @@ body: attributes: label: "Problem Description" description: "What problem occurred? What is the expected normal behavior?" - placeholder: "e.g., When clicking the download button on YouTube page, the script throws a 404 error, expected to show a download window" + placeholder: + "e.g., When clicking the download button on YouTube page, the script throws a 404 error, expected to show a + download window" validations: required: true @@ -37,7 +39,9 @@ body: id: scriptcat-version attributes: label: ScriptCat Version - description: You can view it by clicking on the ScriptCat popup window. If possible, please use the latest version as your issue may have already been resolved + description: + You can view it by clicking on the ScriptCat popup window. If possible, please use the latest version as your + issue may have already been resolved placeholder: e.g., v0.17.0 validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request_en.md b/.github/ISSUE_TEMPLATE/feature_request_en.md index 995a35b67..d5194e850 100644 --- a/.github/ISSUE_TEMPLATE/feature_request_en.md +++ b/.github/ISSUE_TEMPLATE/feature_request_en.md @@ -12,7 +12,8 @@ Please clearly describe the feature you want: ### Use Case -In what situations is this feature needed? (e.g., when processing specific websites, improving operational efficiency, etc.) +In what situations is this feature needed? (e.g., when processing specific websites, improving operational efficiency, +etc.) ### Additional Information diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1bebf61b1..69901b700 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,22 +9,28 @@ When performing a code review, respond in Chinese. ScriptCat is a sophisticated browser extension that executes user scripts with a unique multi-process architecture: ### Core Components -- **Service Worker** (`src/service_worker.ts`) - Main background process handling script management, installations, and chrome APIs + +- **Service Worker** (`src/service_worker.ts`) - Main background process handling script management, installations, and + chrome APIs - **Offscreen** (`src/offscreen.ts`) - Isolated background environment for running background/scheduled scripts - **Sandbox** (`src/sandbox.ts`) - Secure execution environment inside offscreen for script isolation - **Content Scripts** (`src/content.ts`) - Injected into web pages to execute user scripts - **Inject Scripts** (`src/inject.ts`) - Runs in page context with access to page globals ### Message Passing System + ScriptCat uses a sophisticated message passing architecture (`packages/message/`): + - **ExtensionMessage** - Chrome extension runtime messages between service worker/content/pages - **WindowMessage** - PostMessage-based communication between offscreen/sandbox - **CustomEventMessage** - CustomEvent-based communication between content/inject scripts - **MessageQueue** - Cross-environment event broadcasting system -Key pattern: All communication flows through Service Worker → Offscreen → Sandbox for background scripts, or Service Worker → Content → Inject for page scripts. +Key pattern: All communication flows through Service Worker → Offscreen → Sandbox for background scripts, or Service +Worker → Content → Inject for page scripts. ### Script Execution Flow + 1. **Page Scripts**: Service Worker registers with `chrome.userScripts` → injected into pages 2. **Background Scripts**: Service Worker → Offscreen → Sandbox execution 3. **Scheduled Scripts**: Cron-based execution in Sandbox environment @@ -32,6 +38,7 @@ Key pattern: All communication flows through Service Worker → Offscreen → Sa ## Key Development Patterns ### Path Aliases + ```typescript "@App": "./src/" "@Packages": "./packages/" @@ -39,24 +46,30 @@ Key pattern: All communication flows through Service Worker → Offscreen → Sa ``` ### Repository Pattern + All data access uses DAO classes extending `Repo` base class: + ```typescript // Example: ScriptDAO, ResourceDAO, SubscribeDAO export class ScriptDAO extends Repo more'; + expect(stripHtmlTags(html)).toBe("text more"); + }); + + it("should handle empty string", () => { + expect(stripHtmlTags("")).toBe(""); + }); +}); + +describe("WebFetchExecutor", () => { + const mockSender = {} as any; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + }); + + 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); + mockExtract.mockResolvedValue("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); + mockExtract.mockResolvedValue(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); + mockExtract.mockRejectedValue(new Error("Offscreen unavailable")); + + 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); + mockExtract.mockResolvedValue("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); + mockExtract.mockResolvedValue("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(mockExtract).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(mockExtract).not.toHaveBeenCalled(); + }); + + it("should use default max_length of 10000", 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(10000); + expect(result.truncated).toBe(true); + }); +}); diff --git a/src/app/service/agent/tools/web_fetch.ts b/src/app/service/agent/tools/web_fetch.ts new file mode 100644 index 000000000..364ed065b --- /dev/null +++ b/src/app/service/agent/tools/web_fetch.ts @@ -0,0 +1,111 @@ +import type { ToolDefinition } from "@App/app/service/agent/types"; +import type { ToolExecutor } from "@App/app/service/agent/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. Returns extracted text for HTML pages, raw content for JSON/plain text. Use this to read web pages, APIs, or download text content.", + parameters: { + type: "object", + properties: { + url: { type: "string", description: "The URL to fetch (http/https)" }, + max_length: { type: "number", description: "Max characters to return (default 10000)" }, + }, + required: ["url"], + }, +}; + +// 简单正则去 HTML 标签(降级方案) +export function stripHtmlTags(html: string): string { + return html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export class WebFetchExecutor implements ToolExecutor { + constructor(private sender: MessageSend) {} + + async execute(args: Record): Promise { + const url = args.url as string; + const maxLength = (args.max_length as number) || 10000; + + 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); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + 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"; + } + + // 截断 + const truncated = content.length > maxLength; + if (truncated) { + content = content.slice(0, maxLength); + } + + return JSON.stringify({ + url, + content_type: detectedType, + content, + truncated, + }); + } +} diff --git a/src/app/service/agent/tools/web_search.test.ts b/src/app/service/agent/tools/web_search.test.ts new file mode 100644 index 000000000..e713fd14d --- /dev/null +++ b/src/app/service/agent/tools/web_search.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { WebSearchExecutor } from "./web_search"; +import type { SearchConfigRepo } from "./search_config"; + +// Mock offscreen client +vi.mock("@App/app/service/offscreen/client", () => ({ + extractSearchResults: vi.fn(), +})); + +import { extractSearchResults } from "@App/app/service/offscreen/client"; +const mockExtractResults = vi.mocked(extractSearchResults); + +describe("WebSearchExecutor", () => { + const mockSender = {} as any; + + const createMockConfigRepo = (engine: "duckduckgo" | "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()); + }); + + 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); + + mockExtractResults.mockResolvedValue([ + { 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}`, + })); + mockExtractResults.mockResolvedValue(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}`, + })); + mockExtractResults.mockResolvedValue(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")); + }); + + 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 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}`, + })); + mockExtractResults.mockResolvedValue(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/tools/web_search.ts b/src/app/service/agent/tools/web_search.ts new file mode 100644 index 000000000..c9cb3c72f --- /dev/null +++ b/src/app/service/agent/tools/web_search.ts @@ -0,0 +1,91 @@ +import type { ToolDefinition } from "@App/app/service/agent/types"; +import type { ToolExecutor } from "@App/app/service/agent/tool_registry"; +import type { MessageSend } from "@Packages/message/types"; +import type { SearchConfigRepo } from "./search_config"; +import { extractSearchResults } from "@App/app/service/offscreen/client"; + +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"], + }, +}; + +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": + default: + return this.searchDuckDuckGo(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)", + }, + }); + + if (!response.ok) { + throw new Error(`DuckDuckGo search failed: HTTP ${response.status}`); + } + + const html = await response.text(); + const results = await extractSearchResults(this.sender, html); + + return JSON.stringify(results.slice(0, maxResults)); + } + + 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); + + 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/types.ts b/src/app/service/agent/types.ts new file mode 100644 index 000000000..e2f890e72 --- /dev/null +++ b/src/app/service/agent/types.ts @@ -0,0 +1,554 @@ +// ---- 通用类型 ---- + +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 ToolCall = { + id: string; + name: string; + arguments: string; + result?: string; + attachments?: Attachment[]; + 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; + 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 } + | { type: "sub_agent_event"; agentId: string; description: string; event: ChatStreamEvent } + | { type: "new_message" } + | { + type: "done"; + usage?: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens?: number; + cacheReadInputTokens?: number; + }; + durationMs?: number; + } + | { type: "error"; message: string; errorCode?: string }; + +// 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"; + apiBaseUrl: string; + apiKey: string; + model: string; + maxTokens?: number; // 最大输出 token 数,不设置则由 API 端决定 + 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 }; + +// ---- 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 +}; + +// conv.chat() 的参数 +export type ChatOptions = { + tools?: Array) => Promise }>; +}; + +// conv.chat() 的返回值 +export type ChatReply = { + content: MessageContent; + thinking?: string; + toolCalls?: ToolCall[]; + usage?: { inputTokens: number; outputTokens: 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 }; + 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 字段声明 + 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 }; + +// ---- 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; +}; + +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/content/gm_api/cat_agent.test.ts b/src/app/service/content/gm_api/cat_agent.test.ts new file mode 100644 index 000000000..eda2e57f8 --- /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/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..0035c0fdd --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -0,0 +1,581 @@ +import GMContext from "./gm_context"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { MessageConnect } from "@Packages/message/types"; +import type { + ChatReply, + ChatStreamEvent, + CommandHandler, + Conversation, + ConversationApiRequest, + ConversationCreateOptions, + ChatOptions, + StreamChunk, + ToolCall, + ToolDefinition, + ChatMessage, + MessageRole, + MessageContent, +} from "@App/app/service/agent/types"; +import { getTextContent } from "@App/app/service/agent/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[]; + }> = []; + + 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 + ) { + this.ephemeral = ephemeral || 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.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.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, + ]); + } + + // 处理非流式 chat 的响应 + private processChat( + conn: MessageConnect, + handlers: Map) => Promise> + ): Promise { + return new Promise((resolve, reject) => { + let content = ""; + let thinking = ""; + const toolCalls: ToolCall[] = []; + 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 "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; + resolve({ + content, + 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 "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) { + results.push({ id: tc.id, result: JSON.stringify({ error: e.message || "Tool execution failed" }) }); + } + } + + 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 + ); +} + +// 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..d3dd6f961 --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_dom.ts @@ -0,0 +1,133 @@ +// CAT.agent.dom API,注入到脚本上下文 +// 使用 @GMContext.API 装饰器注册到 "CAT.agent.dom" grant + +import GMContext from "./gm_context"; +import type { + DomApiRequest, + ReadPageOptions, + ScreenshotOptions, + DomActionOptions, + NavigateOptions, + ScrollDirection, + ScrollOptions, + WaitForOptions, + ExecuteScriptOptions, + TabInfo, + NavigateResult, + PageContent, + ActionResult, + ScrollResult, + WaitForResult, + MonitorResult, + MonitorStatus, +} from "@App/app/service/agent/types"; + +// 运行时 this 是 GM_Base 实例 +interface GMBaseContext { + sendMessage: (api: string, params: unknown[]) => Promise; + 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..c322e5aba --- /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/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..48422209b --- /dev/null +++ b/src/app/service/content/gm_api/cat_agent_model.ts @@ -0,0 +1,48 @@ +import type { AgentModelSafeConfig, ModelApiRequest } from "@App/app/service/agent/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; + } +} 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..9a5abeaa2 --- /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/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..9d039f9e0 --- /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/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 ba023b3a5..15fbdb13f 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -18,13 +18,25 @@ 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; // 内部函数呼叫定义 export interface IGM_Base { diff --git a/src/app/service/offscreen/client.ts b/src/app/service/offscreen/client.ts index a79421c08..ce4acc830 100644 --- a/src/app/service/offscreen/client.ts +++ b/src/app/service/offscreen/client.ts @@ -37,6 +37,37 @@ 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; +} + +// 搜索结果提取 +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.ts b/src/app/service/offscreen/html_extractor.ts new file mode 100644 index 000000000..8391bfb92 --- /dev/null +++ b/src/app/service/offscreen/html_extractor.ts @@ -0,0 +1,183 @@ +import type { Group } from "@Packages/message/server"; + +export type SearchResult = { + title: string; + url: string; + snippet: string; +}; + +export class HtmlExtractorService { + constructor(private group: Group) {} + + init() { + this.group.on("extractHtmlContent", (html: string) => this.extractHtmlContent(html)); + this.group.on("extractSearchResults", (html: string) => this.extractSearchResults(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(""); + } + } + } + } + + extractSearchResults(html: string): SearchResult[] { + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + const results: SearchResult[] = []; + + // DuckDuckGo HTML 搜索结果 + 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; + } catch { + return []; + } + } +} diff --git a/src/app/service/offscreen/index.ts b/src/app/service/offscreen/index.ts index a3a22328d..e7afe35da 100644 --- a/src/app/service/offscreen/index.ts +++ b/src/app/service/offscreen/index.ts @@ -8,6 +8,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环境的管理器 @@ -57,14 +58,17 @@ export class OffscreenManager { script.init(); // 转发从sandbox来的gm api请求 forwardMessage("serviceWorker", "runtime/gmApi", this.windowServer, this.extMsgSender); + // 转发 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.extMsgSender); 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; 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/agent.test.ts b/src/app/service/service_worker/agent.test.ts new file mode 100644 index 000000000..0fb1916e0 --- /dev/null +++ b/src/app/service/service_worker/agent.test.ts @@ -0,0 +1,2162 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { AgentService, isRetryableError, withRetry, classifyErrorCode } from "./agent"; + +// 创建 mock AgentService 实例 +function createTestService() { + const mockGroup = { on: vi.fn() } as any; + const mockSender = {} as any; + + 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; + + // 替换 repo 和 skillRepo(避免 OPFS 调用) + const mockRepo = { + appendMessage: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockResolvedValue([]), + listConversations: vi.fn().mockResolvedValue([]), + saveConversation: vi.fn().mockResolvedValue(undefined), + saveMessages: vi.fn().mockResolvedValue(undefined), + }; + 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).repo = mockRepo; + (service as any).skillRepo = mockSkillRepo; + + return { service, mockRepo, mockSkillRepo, mockModelRepo }; +} + +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; }`; + +// ---- Skill 系统测试 ---- + +import type { SkillRecord, SkillScriptRecord } from "@App/app/service/agent/types"; + +// 辅助:创建 SkillRecord +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 +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, + }; +} + +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([]); + }); + }); +}); + +// ---- 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"); + }); +}); + +// ---- 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"); + }); +}); + +// ---- 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 }; + } + + // 创建最简单的 OpenAI SSE 响应(纯文本,无 tool call) + 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; + } + + // 已存在于 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: "第二条" }); + }); +}); + +// ---- callLLM 相关测试(通过 callLLMWithToolLoop 间接测试) ---- + +describe("callLLM 流式响应解析", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + // 辅助:创建 OpenAI SSE Response + 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; + } + + // 辅助:创建 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):sendEvent 收到 error + errorCode=auth", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + // 返回 401 错误,errorText 非 JSON 使消息包含 "401"(classifyErrorCode 靠正则匹配) + 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); + + 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 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 () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + + mockRepo.listConversations.mockResolvedValue([BASE_CONV]); + mockRepo.getMessages.mockResolvedValue([]); + + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + body: null, + text: async () => "", + } as unknown as Response); + + await (service as any).handleConversationChat({ conversationId: "conv-1", 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("No response body"); + }); + + 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 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; + } + + 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 makeTextResponse(text: string): Response { + return makeSSEResponse([ + `data: {"choices":[{"delta":{"content":"${text}"}}]}\n\n`, + `data: {"usage":{"prompt_tokens":10,"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("工具调用单轮: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"); + }); +}); + +// ---- handleConversationChat 场景补充 ---- + +describe("handleConversationChat 场景补充", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + 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; + } + + function makeTextResponse(text: string): Response { + return makeSSEResponse([ + `data: {"choices":[{"delta":{"content":"${text}"}}]}\n\n`, + `data: {"usage":{"prompt_tokens":10,"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 }; + } + + 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"); + }); +}); + +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", + }); + expect(models[1]).toEqual({ + id: "m2", + name: "Claude", + provider: "anthropic", + apiBaseUrl: "https://api.anthropic.com", + model: "claude-sonnet-4-20250514", + maxTokens: 4096, + }); + }); + + 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/service_worker/agent.ts b/src/app/service/service_worker/agent.ts new file mode 100644 index 000000000..bf5cbb770 --- /dev/null +++ b/src/app/service/service_worker/agent.ts @@ -0,0 +1,1676 @@ +import type { Group, IGetSender } from "@Packages/message/server"; +import { GetSenderType } from "@Packages/message/server"; +import type { MessageSend } from "@Packages/message/types"; +import type { + AgentModelConfig, + AgentModelSafeConfig, + ChatRequest, + ChatStreamEvent, + ConversationApiRequest, + Conversation, + ToolCall, + ToolDefinition, + DomApiRequest, + SkillApiRequest, + SkillMetadata, + SkillRecord, + SkillSummary, + SkillScriptRecord, + MessageContent, + AgentTask, + AgentTaskApiRequest, + AgentTaskTrigger, + Attachment, + ModelApiRequest, + MCPApiRequest, + ContentBlock, +} from "@App/app/service/agent/types"; +import { getTextContent, isContentBlocks } from "@App/app/service/agent/content_utils"; +import { buildOpenAIRequest, parseOpenAIStream } from "@App/app/service/agent/providers/openai"; +import { buildAnthropicRequest, parseAnthropicStream } from "@App/app/service/agent/providers/anthropic"; +import { AgentChatRepo } from "@App/app/repo/agent_chat"; +import { AgentModelRepo } from "@App/app/repo/agent_model"; +import { SkillRepo } from "@App/app/repo/skill_repo"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import { ToolRegistry } from "@App/app/service/agent/tool_registry"; +import type { ScriptToolCallback, ToolExecutor } from "@App/app/service/agent/tool_registry"; +import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; +import { parseSkillMd, parseSkillZip } from "@App/pkg/utils/skill"; +import { SkillScriptExecutor } from "@App/app/service/agent/skill_script_executor"; +import { CACHE_KEY_SKILL_INSTALL } from "@App/app/cache_key"; +import { buildSystemPrompt, SKILL_SUFFIX_HEADER } from "@App/app/service/agent/system_prompt"; +import { cacheInstance } from "@App/app/cache"; +import { AgentDomService } from "./agent_dom"; +import { MCPService } from "./agent_mcp"; +import { type ResourceService } from "./resource"; +import { AgentTaskRepo, AgentTaskRunRepo } from "@App/app/repo/agent_task"; +import { AgentTaskScheduler } from "@App/app/service/agent/task_scheduler"; +import { InfoNotification } from "./utils"; +import { nextTimeInfo } from "@App/pkg/utils/cron"; +import { sendMessage } from "@Packages/message/client"; +import { WEB_FETCH_DEFINITION, WebFetchExecutor } from "@App/app/service/agent/tools/web_fetch"; +import { WEB_SEARCH_DEFINITION, WebSearchExecutor } from "@App/app/service/agent/tools/web_search"; +import { SearchConfigRepo } from "@App/app/service/agent/tools/search_config"; +import { createTaskTools } from "@App/app/service/agent/tools/task_tools"; +import { createAskUserTool } from "@App/app/service/agent/tools/ask_user"; +import { createSubAgentTool } from "@App/app/service/agent/tools/sub_agent"; + +// 判断是否可重试(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"; +} + +export class AgentService { + private repo = new AgentChatRepo(); + private skillRepo = new SkillRepo(); + private toolRegistry = new ToolRegistry(); + // 已加载的 Skill 缓存 + private skillCache = new Map(); + + private modelRepo = new AgentModelRepo(); + private domService = new AgentDomService(); + private mcpService!: MCPService; + private taskRepo = new AgentTaskRepo(); + private taskRunRepo = new AgentTaskRunRepo(); + private taskScheduler!: AgentTaskScheduler; + + constructor( + private group: Group, + private sender: MessageSend, + private resourceService?: ResourceService + ) {} + + handleDomApi(request: DomApiRequest): Promise { + return this.domService.handleDomApi(request); + } + + init() { + // 注入 chatRepo 到 ToolRegistry 用于保存附件 + this.toolRegistry.setChatRepo(this.repo); + // 初始化 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)); + // 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("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(供 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)); + // 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)); + // 初始化定时任务调度器 + this.taskScheduler = new AgentTaskScheduler( + this.taskRepo, + this.taskRunRepo, + (task) => this.executeInternalTask(task), + (task) => this.emitTaskEvent(task) + ); + this.taskScheduler.init(); + // 注册永久内置工具 + const searchConfigRepo = new SearchConfigRepo(); + this.toolRegistry.registerBuiltin(WEB_FETCH_DEFINITION, new WebFetchExecutor(this.sender)); + this.toolRegistry.registerBuiltin(WEB_SEARCH_DEFINITION, new WebSearchExecutor(this.sender, searchConfigRepo)); + // 加载已安装的 Skills + this.loadSkills(); + } + + // 获取工具注册表(供外部注册内置工具) + getToolRegistry(): ToolRegistry { + return this.toolRegistry; + } + + // 创建 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 到缓存 + private async loadSkills() { + try { + const summaries = await this.skillRepo.listSkills(); + for (const summary of summaries) { + const record = await this.skillRepo.getSkill(summary.name); + if (record) { + 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 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 按需加载完整提示词及脚本描述 + private resolveSkills(skills?: "auto" | string[]): { + promptSuffix: string; + metaTools: Array<{ definition: ToolDefinition; executor: ToolExecutor }>; + } { + if (!skills) { + return { promptSuffix: "", metaTools: [] }; + } + + // 确定要加载的 skill 列表 + let skillRecords: SkillRecord[]; + if (skills === "auto") { + skillRecords = Array.from(this.skillCache.values()); + } else { + 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 }; + } + + // 获取模型配置 + private 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; + } + + // 定时任务调度器 tick,由 alarm handler 调用 + async onSchedulerTick() { + await this.taskScheduler.tick(); + } + + // 处理定时任务 API 请求 + private async handleAgentTask(params: AgentTaskApiRequest) { + 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}`); + } + } + + // internal 模式定时任务执行:构建对话并调用 LLM + private async executeInternalTask( + task: AgentTask + ): Promise<{ conversationId: string; usage?: { inputTokens: number; outputTokens: number } }> { + const model = await this.getModel(task.modelId); + + // 解析 Skills + const { promptSuffix, metaTools } = this.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); + } + + // 注册 task 工具(定时任务无 UI 连接,不注册 ask_user/agent) + const { tools: taskToolDefs } = createTaskTools(); + for (const t of taskToolDefs) { + this.toolRegistry.registerBuiltin(t.definition, t.executor); + registeredMetaToolNames.push(t.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.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 模式定时任务:通知脚本 + private 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, "定时任务已触发"); + } + } + + // 处理 conversation API 请求(非流式),供 GMApi 调用 + async handleConversationApi(params: ConversationApiRequest) { + return this.handleConversation(params); + } + + // 处理定时任务 API 请求,供 GMApi 调用 + async handleAgentTaskApi(params: AgentTaskApiRequest) { + return this.handleAgentTask(params); + } + + // 处理 CAT.agent.model API 请求(只读,隐藏 apiKey),供 GMApi 调用 + private stripApiKey(model: AgentModelConfig): AgentModelSafeConfig { + const { apiKey: _, ...safe } = model; + return safe; + } + + 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(); + default: + throw new Error(`Unknown model API action: ${(request as any).action}`); + } + } + + // 处理流式 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; + }, + sender: IGetSender + ) { + return this.handleConversationChat(params, sender); + } + + // 处理 Sandbox conversation API 请求(非流式) + private async handleConversation(params: ConversationApiRequest) { + switch (params.action) { + case "create": + return this.createConversation(params); + case "get": + return this.getConversation(params.id); + case "getMessages": + return this.repo.getMessages(params.conversationId); + case "save": + // 对话已经在 chat 过程中持久化,这里确保元数据也保存 + return true; + case "clearMessages": + await this.repo.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.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 this.repo.saveConversation(conv); + return conv; + } + + private async getConversation(id: string): Promise { + const conversations = await this.repo.listConversations(); + return conversations.find((c) => c.id === id) || null; + } + + // 统一的 tool calling 循环,UI 和脚本共用 + private 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 }; + + 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.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; + } + + // 构建 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 this.repo.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 ID → Attachment[] + const attachmentUpdates = 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); + } + + // 持久化 tool 结果消息 + if (conversationId) { + await this.repo.appendMessage({ + id: uuidv4(), + conversationId, + role: "tool", + content: tr.result, + toolCallId: tr.id, + createtime: Date.now(), + }); + } + } + + // 回写附件元数据到 assistant 消息的 toolCalls(内存 + 持久化) + if (attachmentUpdates.size > 0) { + // 找到最近的 assistant 消息(刚推入的倒数第 toolResults.length + 1 位) + const assistantMsg = messages.find( + (m) => m.role === "assistant" && m.toolCalls?.some((tc) => attachmentUpdates.has(tc.id)) + ); + if (assistantMsg?.toolCalls) { + for (const tc of assistantMsg.toolCalls) { + const atts = attachmentUpdates.get(tc.id); + if (atts) tc.attachments = atts; + } + // 更新持久化的 assistant 消息 + if (conversationId) { + const allMessages = await this.repo.getMessages(conversationId); + // 找到最后一条有匹配 toolCall 的 assistant 消息 + for (let i = allMessages.length - 1; i >= 0; i--) { + const msg = allMessages[i]; + if (msg.role === "assistant" && msg.toolCalls?.some((tc) => attachmentUpdates.has(tc.id))) { + for (const tc of msg.toolCalls!) { + const atts = attachmentUpdates.get(tc.id); + if (atts) tc.attachments = atts; + } + await this.repo.saveMessages(conversationId, allMessages); + break; + } + } + } + } + } + + // 通知 UI 即将开始新一轮 LLM 调用,创建新的 assistant 消息 + sendEvent({ type: "new_message" }); + + // 继续循环 + continue; + } + + // 没有 tool calls,对话结束 + if (conversationId) { + await this.repo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: buildMessageContent(), + thinking: result.thinking ? { content: result.thinking } : undefined, + createtime: Date.now(), + }); + } + + // 发送 done 事件 + sendEvent({ type: "done", usage: totalUsage, durationMs: Date.now() - startTime }); + return; + } + + // 超过最大迭代次数 + const maxIterMsg = `Tool calling loop exceeded maximum iterations (${maxIterations})`; + if (conversationId) { + await this.repo.appendMessage({ + id: uuidv4(), + conversationId, + role: "assistant", + content: "", + error: maxIterMsg, + createtime: Date.now(), + }); + } + sendEvent({ + type: "error", + message: maxIterMsg, + errorCode: "max_iterations", + }); + } + + // 解析消息中所有 ContentBlock 引用的 attachmentId → base64 data URL + private async resolveAttachments(messages: ChatRequest["messages"]): Promise<(id: string) => string | null> { + const resolved = new Map(); + // attachmentId → mimeType(从 ContentBlock 中提取,OPFS 不保留 MIME 类型) + const mimeTypes = new Map(); + const ids = new Set(); + + for (const m of messages) { + if (isContentBlocks(m.content)) { + for (const block of m.content) { + if (block.type !== "text" && "attachmentId" in block) { + ids.add(block.attachmentId); + if ("mimeType" in block && block.mimeType) { + mimeTypes.set(block.attachmentId, block.mimeType); + } + } + } + } + } + + if (ids.size === 0) return () => null; + + for (const id of ids) { + try { + const blob = await this.repo.getAttachment(id); + if (blob) { + // Blob → base64 data URL + const buffer = await blob.arrayBuffer(); + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const b64 = btoa(binary); + // 优先使用 ContentBlock 中的 mimeType,OPFS 读出的 blob.type 通常为空 + 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; + } + + // 启动子代理执行子任务 + private async runSubAgent(params: { + model: AgentModelConfig; + prompt: string; + sendEvent: (event: ChatStreamEvent) => void; + signal: AbortSignal; + excludeTools: string[]; + maxIterations: number; + }): Promise { + const systemContent = buildSystemPrompt({}); + const messages: ChatRequest["messages"] = [ + { role: "system", content: systemContent }, + { role: "user", content: params.prompt }, + ]; + + let resultContent = ""; + + const subSendEvent = (event: ChatStreamEvent) => { + // 转发事件给父代理 + params.sendEvent(event); + // 收集最终回复内容(new_message 表示新一轮,只取最后一轮的文本) + if (event.type === "new_message") { + resultContent = ""; + } else if (event.type === "content_delta") { + resultContent += event.delta; + } + }; + + await this.callLLMWithToolLoop({ + model: params.model, + messages, + maxIterations: params.maxIterations, + sendEvent: subSendEvent, + signal: params.signal, + scriptToolCallback: null, + excludeTools: params.excludeTools, + cache: false, + }); + + return resultContent || "(sub-agent produced no output)"; + } + + // 统一的流式 conversation chat(UI 和脚本 API 共用) + private 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; + }, + sender: IGetSender + ) { + if (!sender.isType(GetSenderType.CONNECT)) { + throw new Error("Conversation chat requires connect mode"); + } + const msgConn = sender.getConnect()!; + + const abortController = new AbortController(); + let isDisconnected = false; + + msgConn.onDisconnect(() => { + isDisconnected = true; + abortController.abort(); + }); + + const sendEvent = (event: ChatStreamEvent) => { + if (!isDisconnected) { + msgConn.sendMessage({ action: "event", data: event }); + } + }; + + // 构建脚本工具回调:通过 MessageConnect 让 Sandbox 执行 handler + let toolResultResolve: ((results: Array<{ id: string; result: string }>) => void) | null = null; + // ask_user resolvers + const askResolvers = new Map void>(); + + 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); + resolver(msg.data.answer); + } + } + }); + + 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.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.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; + } + + // 获取对话和模型 + 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 this.repo.saveConversation(conv); + } + + const model = await this.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.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 + const { tools: taskToolDefs } = createTaskTools(); + 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: (prompt: string, desc: string) => { + const agentId = uuidv4(); + return this.runSubAgent({ + model, + prompt, + signal: abortController.signal, + sendEvent: (evt) => + sendEvent({ type: "sub_agent_event", agentId, description: desc, event: evt }), + excludeTools: ["ask_user", "agent"], + maxIterations: 20, + }); + }, + }); + this.toolRegistry.registerBuiltin(subAgentTool.definition, subAgentTool.executor); + registeredMetaToolNames.push(subAgentTool.definition.name); + } + + // 加载历史消息 + const existingMessages = await this.repo.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 this.repo.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 this.repo.saveConversation(conv); + } + + try { + // 使用统一的 tool calling 循环 + await this.callLLMWithToolLoop({ + model, + messages, + tools: enableTools ? params.tools : undefined, + maxIterations: params.maxIterations || 30, + sendEvent, + signal: abortController.signal, + scriptToolCallback: enableTools && params.tools && params.tools.length > 0 ? scriptToolCallback : null, + conversationId: params.conversationId, + skipBuiltinTools: !enableTools, + }); + } finally { + // 清理临时注册的 meta-tools + for (const name of registeredMetaToolNames) { + this.toolRegistry.unregisterBuiltin(name); + } + } + } catch (e: any) { + if (abortController.signal.aborted) return; + const errorMsg = e.message || "Unknown error"; + // 持久化错误消息到 OPFS,确保刷新后仍可见 + if (params.conversationId && !params.ephemeral) { + try { + await this.repo.appendMessage({ + id: uuidv4(), + conversationId: params.conversationId, + role: "assistant", + content: "", + error: errorMsg, + createtime: Date.now(), + }); + } catch { + // 持久化失败不阻塞错误事件发送 + } + } + sendEvent({ type: "error", message: errorMsg, errorCode: classifyErrorCode(e) }); + } + } + + // 调用 LLM 并收集完整响应(内部处理流式) + private async callLLM( + model: AgentModelConfig, + params: { messages: ChatRequest["messages"]; tools?: ToolDefinition[]; cache?: boolean }, + sendEvent: (event: ChatStreamEvent) => void, + signal: AbortSignal + ): Promise<{ + content: string; + thinking?: string; + toolCalls?: ToolCall[]; + usage?: { inputTokens: number; outputTokens: number }; + contentBlocks?: ContentBlock[]; + }> { + const chatRequest: ChatRequest = { + conversationId: "", + modelId: model.id, + messages: params.messages, + tools: params.tools, + cache: params.cache, + }; + + // 预解析消息中 ContentBlock 引用的 attachmentId → base64 + const attachmentResolver = await this.resolveAttachments(params.messages); + + const { url, init } = + model.provider === "anthropic" + ? buildAnthropicRequest(model, chatRequest, attachmentResolver) + : buildOpenAIRequest(model, chatRequest, attachmentResolver); + + const 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"); + } + + const reader = response.body.getReader(); + const parseStream = model.provider === "anthropic" ? parseAnthropicStream : parseOpenAIStream; + + // 收集响应 + let content = ""; + let thinking = ""; + const toolCalls: ToolCall[] = []; + let currentToolCall: ToolCall | null = null; + let usage: { inputTokens: number; outputTokens: 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 this.repo.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 blockId = `img_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const mimeType = `image/${subtype}`; + try { + await this.repo.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/service_worker/agent_dom.test.ts b/src/app/service/service_worker/agent_dom.test.ts new file mode 100644 index 000000000..a1e4ce69d --- /dev/null +++ b/src/app/service/service_worker/agent_dom.test.ts @@ -0,0 +1,368 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { AgentDomService } from "./agent_dom"; + +// 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("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); + }); + }); + + 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, 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: "MAIN", + }) + ); + }); + + 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, status: "complete", discarded: false }); + + const result = await service.readPage({ tabId: 1 }); + + expect(result.truncated).toBe(true); + expect(result.totalLength).toBe(500000); + }); + }); + + 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, 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, 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, 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, + active: true, + windowId: 1, + status: "complete", + discarded: false, + }); + mockCaptureVisibleTab.mockResolvedValue("data:image/jpeg;base64,abc123"); + + const result = await service.screenshot({ tabId: 1 }); + + expect(result).toBe("data:image/jpeg;base64,abc123"); + expect(mockCaptureVisibleTab).toHaveBeenCalledWith(1, { format: "jpeg", quality: 80 }); + }); + }); + + describe("executeScript", () => { + it("应在页面中执行代码并返回结果", async () => { + mockTabsGet.mockResolvedValue({ id: 1, 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({ count: 42, items: ["a", "b"] }); + expect(mockExecuteScript).toHaveBeenCalledWith( + expect.objectContaining({ + target: { tabId: 1 }, + world: "MAIN", + }) + ); + }); + + it("应在执行失败时抛出错误", async () => { + mockTabsGet.mockResolvedValue({ id: 1, 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, + discarded: true, + status: "complete", + }); + mockTabsReload.mockResolvedValue(undefined); + // reload 后再次 get + mockTabsGet.mockResolvedValueOnce({ + id: 1, + 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"); + }); + }); +}); diff --git a/src/app/service/service_worker/agent_dom.ts b/src/app/service/service_worker/agent_dom.ts new file mode 100644 index 000000000..352f7dd8a --- /dev/null +++ b/src/app/service/service_worker/agent_dom.ts @@ -0,0 +1,567 @@ +// AgentDomService — DOM 操作核心逻辑,在 Service Worker 中运行 +// 默认模式通过 chrome.scripting.executeScript 操作 +// trusted 模式通过 chrome.debugger CDP 操作 + +import type { + TabInfo, + ActionResult, + PageContent, + ReadPageOptions, + DomActionOptions, + ScreenshotOptions, + NavigateOptions, + ScrollDirection, + ScrollOptions, + ScrollResult, + NavigateResult, + WaitForOptions, + WaitForResult, + DomApiRequest, + ExecuteScriptOptions, +} from "@App/app/service/agent/types"; + +type ReadPageInjectedOptions = { + selector: string | undefined | null; + maxLength: number; + removeTags: string[]; +}; +import type { MonitorResult, MonitorStatus } from "@App/app/service/agent/types"; +import { + withDebugger, + cdpClick, + cdpFill, + cdpScreenshot, + cdpStartMonitor, + cdpStopMonitor, + cdpPeekMonitor, +} from "./agent_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 { + 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: "MAIN", + }); + + 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); + + // 检查 tab 是否前台 active + const tab = await chrome.tabs.get(tabId); + if (!tab.active) { + // 后台 tab 优先用 CDP 截图 + try { + return 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)); + } + + const quality = options?.quality ?? 80; + const updatedTab = await chrome.tabs.get(tabId); + const dataUrl = await chrome.tabs.captureVisibleTab(updatedTab.windowId, { + format: "jpeg", + quality, + }); + return dataUrl; + } + + // 点击元素 + 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: "MAIN", + }); + + 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: "MAIN", + }); + + if (results?.[0]?.result) { + return results[0].result as WaitForResult; + } + + await new Promise((resolve) => setTimeout(resolve, interval)); + } + + return { found: false }; + } + + // 在页面中执行 JavaScript 代码 + async executeScript(code: string, options?: ExecuteScriptOptions): Promise { + 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 results[0].result; + } + + // 启动页面监控(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}`); + } + } + + // ---- 辅助方法 ---- + + // 不可注入脚本的 URL 协议 + private static RESTRICTED_PROTOCOLS = ["chrome:", "chrome-extension:", "edge:", "about:", "devtools:"]; + + // 检查 URL 是否可以注入脚本 + private isRestrictedUrl(url: string | undefined): boolean { + if (!url) return false; + return AgentDomService.RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p)); + } + + // 解析 tabId,未传则获取当前活动 tab + private async resolveTabId(tabId?: number): Promise { + if (tabId) { + const tab = await chrome.tabs.get(tabId); + // 检测是否为受限页面 + if (this.isRestrictedUrl(tab.url)) { + throw new Error( + `Cannot operate on restricted page: ${tab.url}. Browser internal pages and extension pages do not allow script injection. Please specify a regular web page tab.` + ); + } + // 检测 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 + if (this.isRestrictedUrl(tabs[0].url)) { + throw new Error( + `Active tab is a restricted page (${tabs[0].url}) which does not allow script injection. Please use dom_list_tabs to find a regular web page and specify its tabId.` + ); + } + 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: "MAIN", + }); + + // 等待页面稳定 + 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: "MAIN", + }); + + 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/service_worker/agent_dom_cdp.test.ts b/src/app/service/service_worker/agent_dom_cdp.test.ts new file mode 100644 index 000000000..12fed57b9 --- /dev/null +++ b/src/app/service/service_worker/agent_dom_cdp.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } 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(); + +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 "./agent_dom_cdp"; + +// 构造 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/service_worker/agent_dom_cdp.ts b/src/app/service/service_worker/agent_dom_cdp.ts new file mode 100644 index 000000000..7def148d8 --- /dev/null +++ b/src/app/service/service_worker/agent_dom_cdp.ts @@ -0,0 +1,352 @@ +// CDP(Chrome DevTools Protocol)操作封装 +// 通过 chrome.debugger API 实现真实用户输入模拟(isTrusted=true) +// 以及页面监控(dialog 自动处理 + DOM 变化捕获) + +import type { ActionResult, MonitorResult, ScreenshotOptions } from "@App/app/service/agent/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 result = await sendCommand(tabId, "Page.captureScreenshot", { + format: "jpeg", + quality, + captureBeyondViewport: options?.fullPage ?? false, + }); + 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/service_worker/agent_mcp.test.ts b/src/app/service/service_worker/agent_mcp.test.ts new file mode 100644 index 000000000..308a1ff28 --- /dev/null +++ b/src/app/service/service_worker/agent_mcp.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MCPService } from "./agent_mcp"; +import { ToolRegistry } from "@App/app/service/agent/tool_registry"; +import type { MCPClientFactory } from "./agent_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/service_worker/agent_mcp.ts b/src/app/service/service_worker/agent_mcp.ts new file mode 100644 index 000000000..dafe6f1a7 --- /dev/null +++ b/src/app/service/service_worker/agent_mcp.ts @@ -0,0 +1,243 @@ +import type { MCPApiRequest, MCPServerConfig, MCPTool, ToolDefinition } from "@App/app/service/agent/types"; +import { MCPClient } from "@App/app/service/agent/mcp_client"; +import { MCPToolExecutor } from "@App/app/service/agent/mcp_tool_executor"; +import { MCPServerRepo } from "@App/app/repo/mcp_server_repo"; +import type { ToolRegistry } from "@App/app/service/agent/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.registerBuiltin(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.unregisterBuiltin(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/service_worker/client.ts b/src/app/service/service_worker/client.ts index 0961b1f16..bf8e84dc4 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -11,7 +11,14 @@ import { type FileSystemType } from "@Packages/filesystem/factory"; import { type ResourceBackup } from "@App/pkg/backup/struct"; import { type VSCodeConnect } from "../offscreen/vscode-connect"; import { type ScriptInfo } from "@App/pkg/utils/scriptInstall"; -import type { ScriptService, TCheckScriptUpdateOption, TOpenBatchUpdatePageOption } from "./script"; +import type { AgentModelConfig, MCPApiRequest, SkillConfigField } from "@App/app/service/agent/types"; +import type { + ScriptService, + TCheckScriptUpdateOption, + TOpenBatchUpdatePageOption, + TScriptInstallParam, + TScriptInstallReturn, +} from "./script"; import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { type TSetValuesParams } from "./value"; @@ -40,15 +47,9 @@ export class ScriptClient extends Client { return this.do<[boolean, ScriptInfo, { byWebRequest?: boolean }]>("getInstallInfo", uuid); } - install(params: { - script: Script; - code: string; - upsertBy?: InstallSource; - createtime?: number; - updatetime?: number; - }): Promise<{ update: boolean }> { + install(params: TScriptInstallParam): Promise { if (!params.upsertBy) params.upsertBy = "user"; - return this.doThrow("install", { ...params }); + return this.doThrow("install", { ...params } satisfies TScriptInstallParam); } // delete(uuid: string) { @@ -328,3 +329,90 @@ 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); + } + + 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); + } + + // 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..7518b5bed --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_agent.ts @@ -0,0 +1,57 @@ +// 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/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); + } +} + +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..7bbea8107 --- /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/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..4e7661bba --- /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/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_skills.ts b/src/app/service/service_worker/gm_api/gm_agent_skills.ts new file mode 100644 index 000000000..f5215216a --- /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/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..d689d0520 --- /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/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 1f853e284..7de6c8a28 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 { type IGetSender, type Group, GetSenderType } from "@Packages/message/server"; import type { ExtMessageSender, MessageSend, TMessageCommAction } from "@Packages/message/types"; import { connect, sendMessage } from "@Packages/message/client"; @@ -28,6 +28,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/skill_script_executor"; import i18n from "@App/locales/locales"; import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { createObjectURL } from "../../offscreen/client"; @@ -45,6 +50,19 @@ 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 "../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; let generatedUniqueMarkerIDs = ""; let generatedUniqueMarkerIDWhen = ""; @@ -237,9 +255,11 @@ export default class GMApi { scriptDAO: ScriptDAO = new ScriptDAO(); + agentService?: AgentService; + constructor( private systemConfig: SystemConfig, - private permissionVerify: PermissionVerify, + public permissionVerify: PermissionVerify, private group: Group, private msgSender: MessageSend, private mq: IMessageQueue, @@ -249,6 +269,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) { @@ -269,11 +293,36 @@ 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"); + } } - 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 51f381283..3e1d6327c 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -21,6 +21,7 @@ import { FaviconDAO } from "@App/app/repo/favicon"; import { onRegularUpdateCheckAlarm } from "./regular_updatecheck"; import { cacheInstance } from "@App/app/cache"; import { InfoNotification } from "./utils"; +import { AgentService } from "./agent"; // service worker的管理器 export default class ServiceWorkerManager { @@ -101,6 +102,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 8ea36659e..3e0992057 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -23,6 +23,7 @@ import { obtainBlackList, sourceMapTo, } from "@App/pkg/utils/utils"; +import { BrowserType, getBrowserInstalledVersion, getBrowserType, isPermissionOk } from "@App/pkg/utils/utils"; import { cacheInstance } from "@App/app/cache"; import { UrlMatch } from "@App/pkg/utils/match"; import { ExtensionContentMessageSend } from "@Packages/message/extension_message"; @@ -86,6 +87,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; @@ -173,22 +179,8 @@ export class RuntimeService { } } - showNoDeveloperModeWarning() { - // 判断是否首次 - this.localStorageDAO.get("firstShowDeveloperMode").then((res) => { - if (!res) { - this.localStorageDAO.save({ - key: "firstShowDeveloperMode", - value: true, - }); - // 打开页面 - initLocalesPromise.then(() => { - chrome.tabs.create({ - url: `${DocumentationSite}${localePath}/docs/use/open-dev/`, - }); - }); - } - }); + async showUserscriptActivationGuide() { + const storageKey = "firstShowDeveloperMode"; chrome.action.setBadgeBackgroundColor({ color: "#ff8c00", }); @@ -217,6 +209,35 @@ export class RuntimeService { }); } }); + + const currentInstalledBrowser = getBrowserInstalledVersion(); + const lastInstalledBrowser = (await this.localStorageDAO.get(storageKey))?.value as string | boolean | undefined; + // 判断是否安装后的首次,或是浏览器升级后的首次 + if (currentInstalledBrowser === lastInstalledBrowser) return; // 非首次则不弹出页面 + + const savePromise = this.localStorageDAO.save({ + key: storageKey, + value: currentInstalledBrowser, + }); + await Promise.allSettled([initLocalesPromise, this.initReady, savePromise]); // 等一下语言加载和 isUserScriptsAvailable 检查之类的 + + const userscript_enabled: boolean = this.isUserScriptsAvailable; + const permission = await isPermissionOk("userScripts"); + const browserType = getBrowserType(); + const guard = + browserType.chrome & BrowserType.guardedByDeveloperMode + ? "developerMode" + : browserType.chrome & BrowserType.guardedByAllowScript + ? "allowScript" + : "none"; + + // 打开页面 + const path = `${DocumentationSite}${localePath}/docs/use/open-dev/`; + let search = `?userscript_enabled=${userscript_enabled}&userscript_permission=${permission}&userscript_guard=${guard}`; + if (browserType.chrome & BrowserType.Edge) search += "&browser=edge"; + else if (browserType.chrome & BrowserType.Chrome) search += "&browser=chrome"; + const hash = `${guard === "developerMode" ? "#enable-developer-mode" : guard === "allowScript" ? "#allow-user-scripts" : ""}`; + chrome.tabs.create({ url: `${path}${search}${hash}` }); } async getInjectJsCode() { @@ -351,7 +372,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, @@ -361,7 +382,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)); @@ -581,7 +602,7 @@ export class RuntimeService { // 检查是否开启了开发者模式 if (!this.isUserScriptsAvailable) { // 未开启加上警告引导 - this.showNoDeveloperModeWarning(); + this.showUserscriptActivationGuide(); let cid: ReturnType | number; cid = setInterval(async () => { if (!this.isUserScriptsAvailable) { diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 227be28d1..cf1a75fe9 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) @@ -54,6 +55,19 @@ export type TCheckScriptUpdateOption = Partial< export type TOpenBatchUpdatePageOption = { q: string; dontCheckNow: boolean }; +export type TScriptInstallParam = { + script: Script; // 脚本信息(包含脚本的基础元数据) + code: string; // 脚本源码内容 + upsertBy?: InstallSource; // 安装/更新来源(用于标识脚本来源渠道) + createtime?: number; // 导入时指定的创建时间(时间戳,毫秒) + updatetime?: number; // 导入时指定的最后更新时间(时间戳,毫秒) +}; + +export type TScriptInstallReturn = { + update: boolean; // 是否为更新操作(true 表示更新,false 表示新增) + updatetime: number | undefined; // 实际生效的更新时间(时间戳,毫秒) +}; + export class ScriptService { logger: Logger; scriptCodeDAO: ScriptCodeDAO = new ScriptCodeDAO(); @@ -85,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); @@ -153,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" }, ], } ); @@ -237,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) => { @@ -369,14 +392,8 @@ export class ScriptService { return this.mq.publish("installScript", { script, ...options }); } - // 安装脚本 / 更新腳本 - async installScript(param: { - script: Script; - code: string; - upsertBy?: InstallSource; - createtime?: number; - updatetime?: number; - }) { + // 安装脚本 / 更新脚本 + async installScript(param: TScriptInstallParam): Promise { param.upsertBy = param.upsertBy || "user"; const { script, upsertBy, createtime, updatetime } = param; // 删 storage cache @@ -427,10 +444,11 @@ export class ScriptService { ]); // 广播一下 - // Runtime 會負責更新 CompiledResource + // Runtime 会负责更新 CompiledResource this.publishInstallScript(script, { update, upsertBy }); - return { update }; + // 传回(由后台控制的)实际更新时间,让 editor 中的script能保持正确的更新时间 + return { update, updatetime: script.updatetime }; }) .catch((e: any) => { logger.error("install error", Logger.E(e)); @@ -764,17 +782,51 @@ export class ScriptService { setTimeout(resolve, Math.round(MIN_DELAY + ((++i / n + Math.random()) / 2) * (MAX_DELAY - MIN_DELAY))) ); - return Promise.all( - (uuids as string[]).map(async (uuid, _idx) => { - const script = scripts[_idx]; - const res = - !script || script.uuid !== uuid || !checkScripts.includes(script) - ? false - : await this._checkUpdateAvailable(script, delayFn); - if (!res) return false; - return res; - }) - ); + const CHECK_UPDATE_TIMEOUT_MS = 300_000; // 5 分钟超时 + + const results = new Map< + string, + | false + | { + updateAvailable: true; + code: string; + metadata: Partial>; + } + >(); + + // 预初始化 Map 确保顺序 + for (const uuid of uuids as string[]) { + results.set(uuid, false); + } + + const abortController = new AbortController(); + let timeoutId: ReturnType; + + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + abortController.abort(); + resolve(); + }, CHECK_UPDATE_TIMEOUT_MS); + }); + + await Promise.race([ + timeoutPromise, + Promise.allSettled( + (uuids as string[]).map(async (uuid, _idx) => { + const script = scripts[_idx]; + const res = + !script || script.uuid !== uuid || !checkScripts.includes(script) + ? false + : await this._checkUpdateAvailable(script, delayFn, abortController.signal); + if (!res) return false; + results.set(uuid, res); + return res; + }) + ).finally(() => { + clearTimeout(timeoutId); + }), + ]); + return [...results.values()]; } async _checkUpdateAvailable( @@ -784,7 +836,8 @@ export class ScriptService { checkUpdateUrl?: string; metadata: Partial>; }, - delayFn?: () => Promise + delayFn?: () => Promise, + signal?: AbortSignal ): Promise { const { uuid, name, checkUpdateUrl } = script; @@ -796,8 +849,12 @@ export class ScriptService { name, }); try { - if (delayFn) await delayFn(); - const code = await fetchScriptBody(checkUpdateUrl); + if (delayFn) { + if (signal?.aborted) return false; + await delayFn(); + } + if (signal?.aborted) return false; + const code = await fetchScriptBody(checkUpdateUrl, signal); const metadata = parseMetadata(code); if (!metadata) { logger.error("parse metadata failed"); @@ -855,6 +912,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"); @@ -1144,7 +1213,7 @@ export class ScriptService { } isInstalled({ name, namespace }: { name: string; namespace: string }): Promise { - // 用於 window.external + // 用于 window.external return this.scriptDAO.findByNameAndNamespace(name, namespace).then((script) => { if (script) { return { diff --git a/src/assets/_locales/en/messages.json b/src/assets/_locales/en/messages.json index 4165381cb..07481b4e3 100644 --- a/src/assets/_locales/en/messages.json +++ b/src/assets/_locales/en/messages.json @@ -11,4 +11,4 @@ "scriptcat_description": { "message": "Everything can be scripted, allowing your browser to do more!" } -} \ No newline at end of file +} diff --git a/src/assets/_locales/ru/messages.json b/src/assets/_locales/ru/messages.json index cdc009c96..cb36c4f46 100644 --- a/src/assets/_locales/ru/messages.json +++ b/src/assets/_locales/ru/messages.json @@ -11,4 +11,4 @@ "scriptcat_description": { "message": "Всё можно автоматизировать скриптами, позволяя вашему браузеру делать больше!" } -} \ No newline at end of file +} diff --git a/src/assets/_locales/vi/messages.json b/src/assets/_locales/vi/messages.json index 24a61e888..b501e3572 100644 --- a/src/assets/_locales/vi/messages.json +++ b/src/assets/_locales/vi/messages.json @@ -11,4 +11,4 @@ "scriptcat_description": { "message": "Mọi thứ đều có thể viết được, cho phép trình duyệt của bạn làm được nhiều việc hơn!" } -} \ No newline at end of file +} diff --git a/src/assets/_locales/zh_CN/messages.json b/src/assets/_locales/zh_CN/messages.json index 0b7d5775f..a4488bbdf 100644 --- a/src/assets/_locales/zh_CN/messages.json +++ b/src/assets/_locales/zh_CN/messages.json @@ -11,4 +11,4 @@ "scriptcat_description": { "message": "万物皆可脚本化,让你的浏览器可以做更多的事情!" } -} \ No newline at end of file +} diff --git a/src/index.css b/src/index.css index 6eb4944b4..6c46ac7e4 100644 --- a/src/index.css +++ b/src/index.css @@ -3,34 +3,34 @@ @unocss; body { - scrollbar-color: var(--color-scrollbar-thumb) var(--color-scrollbar-track); - /* 对于webkit浏览器的滚动条样式 */ - scrollbar-width: thin; + scrollbar-color: var(--color-scrollbar-thumb) var(--color-scrollbar-track); + /* 对于webkit浏览器的滚动条样式 */ + scrollbar-width: thin; } -body[arco-theme='dark'] { - --color-scrollbar-thumb: #6b6b6b; - --color-scrollbar-track: #2d2d2d; - --color-scrollbar-thumb-hover: #8c8c8c; +body[arco-theme="dark"] { + --color-scrollbar-thumb: #6b6b6b; + --color-scrollbar-track: #2d2d2d; + --color-scrollbar-thumb-hover: #8c8c8c; } -body[arco-theme='light'] { - --color-scrollbar-thumb: #6b6b6b; - --color-scrollbar-track: #f0f0f0; - --color-scrollbar-thumb-hover: #8c8c8c; +body[arco-theme="light"] { + --color-scrollbar-thumb: #6b6b6b; + --color-scrollbar-track: #f0f0f0; + --color-scrollbar-thumb-hover: #8c8c8c; } :root { - --sc-zero-1: 0; - --sc-zero-2: 0; - --sc-zero-3: 0; - --sc-zero-4: 0; + --sc-zero-1: 0; + --sc-zero-2: 0; + --sc-zero-3: 0; + --sc-zero-4: 0; } /* 自定义 .sc-inset-0 避免打包成 inset: 0 使旧浏览器布局错位 */ .sc-inset-0 { - top: var(--sc-zero-1); - left: var(--sc-zero-2); - right: var(--sc-zero-3); - bottom: var(--sc-zero-4); + top: var(--sc-zero-1); + left: var(--sc-zero-2); + right: var(--sc-zero-3); + bottom: var(--sc-zero-4); } diff --git a/src/locales/README.md b/src/locales/README.md index de687d95b..279bf3817 100644 --- a/src/locales/README.md +++ b/src/locales/README.md @@ -25,8 +25,8 @@ for example: ### Help Us Translate -[Crowdin](https://crowdin.com/project/scriptcat) -is an online localization platform that helps us manage translations. If you're interested in helping us translate ScriptCat, you can find the project on Crowdin and start contributing. +[Crowdin](https://crowdin.com/project/scriptcat) is an online localization platform that helps us manage translations. +If you're interested in helping us translate ScriptCat, you can find the project on Crowdin and start contributing. - `src/locales` is the translation file directory for the [extension](https://github.com/scriptscat/scriptcat) diff --git a/src/locales/ach-UG/translation.json b/src/locales/ach-UG/translation.json index e6f3850b5..8b1b621dc 100644 --- a/src/locales/ach-UG/translation.json +++ b/src/locales/ach-UG/translation.json @@ -428,7 +428,6 @@ "develop_mode_guide": "crwdns8624:0crwdne8624:0", "allow_user_script_guide": "crwdns8626:0crwdne8626:0", "lower_version_browser_guide": "crwdns8628:0crwdne8628:0", - "confirm_leave_page": "crwdns8634:0crwdne8634:0", "page_in_blacklist": "crwdns8636:0crwdne8636:0", "baidu_netdisk": "crwdns8638:0crwdne8638:0", "save_only_current_group": "crwdns8640:0crwdne8640:0", @@ -557,5 +556,128 @@ "editor": { "show_script_list": "Show Script List", "hide_script_list": "Hide Script List" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Fetch Models", + "agent_model_fetch_failed": "Failed to fetch models", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_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_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_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" +} diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 9df67cfd8..5cfc506fd 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -438,7 +438,6 @@ "allow_user_script_guide": "'Nutzerskripts zulassen' ist derzeit nicht aktiviert, daher können die Skripte nicht richtig ausgeführt werden. 👉Hier klicken, um zu erfahren, wie man es aktiviert", "lower_version_browser_guide": "Ihr Browser ist zu veraltet, daher können die Skripte nicht richtig ausgeführt werden. 👉Hier klicken, um mehr zu erfahren", "click_to_reload": "👉Zum Neuladen klicken", - "confirm_leave_page": "Derzeit im Bearbeitungsstatus. Das Navigieren zu anderen Seiten führt zum Verlust des aktuellen Inhalts. Navigieren?", "page_in_blacklist": "Die aktuelle Seite ist auf der Blacklist und kann keine Skripte verwenden", "baidu_netdisk": "Baidu Netdisk", "netdisk_unbind": "{{provider}} trennen", @@ -454,7 +453,7 @@ "expression_format_error": "Ausdrucksformat-Fehler", "migration_confirm_message": "Das erneute Versuchen der Speicher-Engine-Migration wird vorhandene Daten ändern. Bitte bestätigen Sie. Details siehe: https://docs.scriptcat.org/docs/change/v0.17/", "retry_migration": "Speicher-Engine-Migration erneut versuchen", - "script_modified_leave_confirm": "Skript wurde geändert. Das Verlassen führt zum Verlust der Änderungen. Fortfahren?", + "script_modified_leave_confirm": "Ihre Änderungen wurden noch nicht gespeichert. Wenn Sie die Seite verlassen, gehen die Änderungen verloren. Möchten Sie die Seite wirklich verlassen?", "create_success_note": "Erstellung erfolgreich. Beachten Sie, dass Hintergrundskripte standardmäßig nicht aktiviert werden", "save_as_failed": "Speichern unter fehlgeschlagen", "save_as_success": "Speichern unter erfolgreich", @@ -581,8 +580,136 @@ "maybe_later": "Vielleicht später", "settings_hint": "Sie können diese Option jederzeit in den Einstellungen ändern." }, + "favicon_service": "Favicon-Dienst", + "favicon_service_desc": "Dienst zum Abrufen von Website-Symbolen auswählen", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "Lokal abrufen", + "favicon_service_google": "Google", "editor": { "show_script_list": "Skriptliste anzeigen", "hide_script_list": "Skriptliste ausblenden" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Modelle abrufen", + "agent_model_fetch_failed": "Modellliste konnte nicht abgerufen werden", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_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_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_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" +} diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 9769ef7f7..b9ef7c224 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -438,7 +438,6 @@ "allow_user_script_guide": "'Allow User Scripts' is currently not enabled, so the scripts cannot run properly. 👉tap to learn how to enable", "lower_version_browser_guide": "Your browser is too outdated, so the scripts cannot run properly. 👉Click me to learn more", "click_to_reload": "👉Click to Reload", - "confirm_leave_page": "Currently editing status. Leaving this page will lose the current content. Do you want to leave?", "page_in_blacklist": "The current page is blacklisted, cannot use script", "baidu_netdisk": "BaiduNetdisk", "netdisk_unbind": "Unbind {{provider}}", @@ -454,7 +453,7 @@ "expression_format_error": "Condition expression format error", "migration_confirm_message": "Retry the migration storage engine to modify existing data. Please confirm, see:https://docs.scriptcat.org/docs/change/v0.17/.", "retry_migration": "Retry Migration Storage Engine", - "script_modified_leave_confirm": "The script has been modified. Changes will be lost after leaving, continue?", + "script_modified_leave_confirm": "Your changes have not been saved. If you leave, your changes will be lost. Are you sure you want to leave?", "create_success_note": "New script successfully created. Note that the background script will not be enabled by default.", "save_as_failed": "Save As Failed", "save_as_success": "Saved Successfully", @@ -581,8 +580,172 @@ "maybe_later": "Maybe Later", "settings_hint": "You can change this option in settings at any time." }, + "favicon_service": "Favicon Service", + "favicon_service_desc": "Choose the service for fetching website icons", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "Local Fetch", + "favicon_service_google": "Google", "editor": { "show_script_list": "Show Script List", "hide_script_list": "Hide Script List" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Fetch Models", + "agent_model_fetch_failed": "Failed to fetch models", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_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_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_newline": "for new line", + "agent_chat_attach_image": "Attach image", + "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_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_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" +} diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index 823949c52..10bfdf99b 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -438,7 +438,6 @@ "allow_user_script_guide": "現在「ユーザー スクリプトを許可する」が有効ではないため、スクリプトは正常に動作しません。👉有効化の方法はこちら", "lower_version_browser_guide": "ご使用のブラウザは古すぎるため、スクリプトは正常に動作しません。👉詳しくはこちら", "click_to_reload": "👉再読み込みする", - "confirm_leave_page": "現在編集中です。他のページに移動すると現在の内容が失われます。移動しますか?", "page_in_blacklist": "現在のページはブラックリストにあり、スクリプトを使用できません", "baidu_netdisk": "百度ネットディスク", "netdisk_unbind": "{{provider}} の連携を解除", @@ -454,7 +453,7 @@ "expression_format_error": "式フォーマットエラー", "migration_confirm_message": "ストレージエンジンの移行を再試行すると既存のデータが変更されます。確認してください。詳細はこちら:https://docs.scriptcat.org/docs/change/v0.17/", "retry_migration": "ストレージエンジンの移行を再試行", - "script_modified_leave_confirm": "スクリプトが変更されています。離れると変更が失われます。続行しますか?", + "script_modified_leave_confirm": "現在の内容はまだ保存されていません。このままページを離れると変更は失われます。よろしいですか?", "create_success_note": "作成に成功しました。バックグラウンドスクリプトはデフォルトで有効になりませんのでご注意ください", "save_as_failed": "名前を付けて保存に失敗しました", "save_as_success": "名前を付けて保存に成功しました", @@ -581,8 +580,136 @@ "maybe_later": "後で", "settings_hint": "設定ページでいつでも変更できます。" }, + "favicon_service": "Favicon サービス", + "favicon_service_desc": "ウェブサイトアイコンの取得サービスを選択", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "ローカル取得", + "favicon_service_google": "Google", "editor": { "show_script_list": "スクリプトリストを表示", "hide_script_list": "スクリプトリストを非表示" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "チャット", + "agent_provider": "モデルサービス", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "モデルサービス", + "agent_provider_select": "AIサービスプロバイダー", + "agent_provider_api_base_url": "APIアドレス", + "agent_provider_api_key": "APIキー", + "agent_provider_model": "デフォルトモデル", + "agent_provider_test_connection": "接続テスト", + "agent_provider_test_success": "接続成功", + "agent_provider_test_failed": "接続失敗", + "agent_model_fetch": "モデルを取得", + "agent_model_fetch_failed": "モデルリストの取得に失敗しました", + "agent_model_name": "名前", + "agent_model_add": "モデルを追加", + "agent_model_edit": "編集", + "agent_model_delete": "削除", + "agent_model_set_default": "デフォルトに設定", + "agent_model_default_label": "デフォルト", + "agent_model_delete_confirm": "このモデル設定を削除してもよろしいですか?", + "agent_model_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_delete_round": "削除", + "agent_chat_copy_message": "コピー", + "agent_chat_edit_message": "編集", + "agent_chat_save_and_send": "保存して送信", + "agent_chat_cancel_edit": "キャンセル", + "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": "パラメータ" +} diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index 1b6b72973..4b93b0377 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -438,7 +438,6 @@ "allow_user_script_guide": "«Разрешить пользовательские скрипты» сейчас отключён, поэтому скрипты не могут работать корректно. 👉Нажмите, чтобы узнать, как включить", "lower_version_browser_guide": "Ваш браузер слишком устарел, поэтому скрипты не могут работать корректно. 👉Нажмите, чтобы узнать подробнее", "click_to_reload": "👉Нажмите для перезагрузки", - "confirm_leave_page": "В настоящее время идет редактирование. Переход на другую страницу приведет к потере текущего содержимого. Продолжить переход?", "page_in_blacklist": "Текущая страница находится в черном списке, невозможно использовать скрипты", "baidu_netdisk": "Baidu Netdisk", "netdisk_unbind": "Отвязать {{provider}}", @@ -454,7 +453,7 @@ "expression_format_error": "Ошибка формата выражения", "migration_confirm_message": "Повторная попытка миграции движка хранения изменит существующие данные. Пожалуйста, подтвердите. Подробности см.: https://docs.scriptcat.org/docs/change/v0.17/", "retry_migration": "Повторить миграцию движка хранения", - "script_modified_leave_confirm": "Скрипт был изменен. Изменения будут потеряны после выхода. Продолжить?", + "script_modified_leave_confirm": "Изменения не сохранены. Если вы покинете страницу, они будут потеряны. Вы уверены, что хотите уйти?", "create_success_note": "Создание успешно. Обратите внимание, что фоновые скрипты не включаются по умолчанию", "save_as_failed": "Ошибка «Сохранить как»", "save_as_success": "«Сохранить как» успешно", @@ -581,8 +580,136 @@ "maybe_later": "Может быть позже", "settings_hint": "Вы можете изменить эту опцию в настройках в любое время." }, + "favicon_service": "Сервис Favicon", + "favicon_service_desc": "Выберите сервис для получения значков сайтов", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "Локальное получение", + "favicon_service_google": "Google", "editor": { "show_script_list": "Показать список скриптов", "hide_script_list": "Скрыть список скриптов" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Получить модели", + "agent_model_fetch_failed": "Не удалось получить список моделей", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_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_delete_round": "Delete", + "agent_chat_copy_message": "Copy", + "agent_chat_edit_message": "Редактировать", + "agent_chat_save_and_send": "Сохранить и отправить", + "agent_chat_cancel_edit": "Отмена", + "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": "Параметры" +} diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index c69cb10be..5d53f4ad0 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -438,7 +438,6 @@ "allow_user_script_guide": "'Cho phép tập lệnh người dùng' hiện chưa được bật, nên các script không thể hoạt động đúng cách. 👉Nhấn để xem cách bật", "lower_version_browser_guide": "Trình duyệt của bạn quá cũ, nên các script không thể hoạt động đúng cách. 👉Nhấn để xem thêm", "click_to_reload": "👉Nhấp chuột để tải lại", - "confirm_leave_page": "Hiện đang ở trạng thái chỉnh sửa. Rời khỏi trang này sẽ làm mất nội dung hiện tại. Bạn có muốn rời đi không?", "page_in_blacklist": "Trang hiện tại nằm trong danh sách đen, không thể sử dụng script", "baidu_netdisk": "Baidunetdisk", "netdisk_unbind": "Hủy liên kết {{provider}}", @@ -454,7 +453,7 @@ "expression_format_error": "Lỗi định dạng biểu thức điều kiện", "migration_confirm_message": "Thử lại công cụ lưu trữ di chuyển để sửa đổi dữ liệu hiện có. Vui lòng xác nhận, xem: https://docs.scriptcat.org/docs/change/v0.17/.", "retry_migration": "Thử lại công cụ lưu trữ di chuyển", - "script_modified_leave_confirm": "Script đã được sửa đổi. Các thay đổi sẽ bị mất sau khi rời đi, tiếp tục?", + "script_modified_leave_confirm": "Nội dung hiện chưa được lưu. Nếu rời khỏi trang, các thay đổi sẽ bị mất. Bạn có chắc chắn muốn rời đi không?", "create_success_note": "Script mới được tạo thành công. Lưu ý rằng script nền sẽ không được bật theo mặc định.", "save_as_failed": "Lưu thành thất bại", "save_as_success": "Lưu thành công", @@ -581,8 +580,136 @@ "maybe_later": "Để sau", "settings_hint": "Bạn có thể thay đổi tùy chọn này trong cài đặt bất kỳ lúc nào." }, + "favicon_service": "Dịch vụ Favicon", + "favicon_service_desc": "Chọn dịch vụ để lấy biểu tượng trang web", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "Lấy cục bộ", + "favicon_service_google": "Google", "editor": { "show_script_list": "Hiển thị danh sách script", "hide_script_list": "Ẩn danh sách script" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "Chat", + "agent_provider": "Model Service", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "Model Service", + "agent_provider_select": "AI Provider", + "agent_provider_api_base_url": "API Base URL", + "agent_provider_api_key": "API Key", + "agent_provider_model": "Default Model", + "agent_provider_test_connection": "Test Connection", + "agent_provider_test_success": "Connection Successful", + "agent_provider_test_failed": "Connection Failed", + "agent_model_fetch": "Fetch Models", + "agent_model_fetch_failed": "Failed to fetch models", + "agent_model_name": "Name", + "agent_model_add": "Add Model", + "agent_model_edit": "Edit", + "agent_model_delete": "Delete", + "agent_model_set_default": "Set as Default", + "agent_model_default_label": "Default", + "agent_model_delete_confirm": "Are you sure to delete this model?", + "agent_model_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_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_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ố" +} diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 1267d3472..1b0d4aeae 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -438,7 +438,6 @@ "allow_user_script_guide": "当前未启用“允许运行用户脚本”,脚本无法正常运行。👉点击查看启用方法", "lower_version_browser_guide": "您的浏览器版本过低,脚本无法正常运行。👉点击了解更多", "click_to_reload": "👉点击重新加载", - "confirm_leave_page": "当前正在编辑状态,跳转其它页面将会丢失当前内容,是否跳转?", "page_in_blacklist": "当前页面在黑名单中,无法使用脚本", "baidu_netdisk": "百度网盘", "netdisk_unbind": "解除绑定 {{provider}}", @@ -454,7 +453,7 @@ "expression_format_error": "表达式格式错误", "migration_confirm_message": "重试迁移储存引擎会对现有数据造成修改,请确认,详情请看:https://docs.scriptcat.org/docs/change/v0.17/", "retry_migration": "重试迁移储存引擎", - "script_modified_leave_confirm": "脚本已修改, 离开后会丢失修改, 是否继续?", + "script_modified_leave_confirm": "当前内容尚未保存,离开后更改将丢失,确定要离开吗?", "create_success_note": "新建成功,请注意后台脚本不会默认开启", "save_as_failed": "另存为失败", "save_as_success": "另存为成功", @@ -581,8 +580,172 @@ "maybe_later": "暂不启用", "settings_hint": "你可以随时在设置中修改此选项。" }, + "favicon_service": "图标服务", + "favicon_service_desc": "选择获取网站图标的服务", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "本地获取", + "favicon_service_google": "Google", "editor": { "show_script_list": "显示脚本列表", "hide_script_list": "隐藏脚本列表" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "会话", + "agent_provider": "模型服务", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "模型服务", + "agent_provider_select": "AI 服务提供商", + "agent_provider_api_base_url": "API 地址", + "agent_provider_api_key": "API 密钥", + "agent_provider_model": "默认模型", + "agent_provider_test_connection": "测试连接", + "agent_provider_test_success": "连接成功", + "agent_provider_test_failed": "连接失败", + "agent_model_fetch": "获取模型", + "agent_model_fetch_failed": "获取模型列表失败", + "agent_model_name": "名称", + "agent_model_add": "添加模型", + "agent_model_edit": "编辑", + "agent_model_delete": "删除", + "agent_model_set_default": "设为默认", + "agent_model_default_label": "默认", + "agent_model_delete_confirm": "确定要删除此模型配置吗?", + "agent_model_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_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_newline": "换行", + "agent_chat_attach_image": "添加图片", + "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_delete_round": "删除", + "agent_chat_copy_message": "复制", + "agent_chat_edit_message": "编辑", + "agent_chat_save_and_send": "保存并发送", + "agent_chat_cancel_edit": "取消", + "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": "未运行" +} diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index a56ddd57e..00701718b 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -438,7 +438,6 @@ "allow_user_script_guide": "目前尚未啟用「允許使用者指令碼」,腳本無法正常執行。👉點此查看啟用方式", "lower_version_browser_guide": "您的瀏覽器版本過舊,腳本無法正常執行。👉點擊了解更多", "click_to_reload": "👉點擊重新載入", - "confirm_leave_page": "目前正在編輯狀態,跳轉其他頁面將會遺失目前內容,是否跳轉?", "page_in_blacklist": "目前頁面在黑名單中,無法使用腳本", "baidu_netdisk": "百度網盤", "netdisk_unbind": "解除綁定 {{provider}}", @@ -454,7 +453,7 @@ "expression_format_error": "表達式格式錯誤", "migration_confirm_message": "重試遷移儲存引擎會對現有資料造成修改,請確認,詳情請參閱:https://docs.scriptcat.org/docs/change/v0.17/", "retry_migration": "重試遷移儲存引擎", - "script_modified_leave_confirm": "腳本已修改,離開後會遺失修改,是否繼續?", + "script_modified_leave_confirm": "目前內容尚未儲存,離開後變更將會遺失,確定要離開嗎?", "create_success_note": "新建成功,請注意背景腳本不會預設開啟", "save_as_failed": "另存新檔失敗", "save_as_success": "另存新檔成功", @@ -581,8 +580,136 @@ "maybe_later": "暫不啟用", "settings_hint": "你可以隨時在設定中修改此選項。" }, + "favicon_service": "圖示服務", + "favicon_service_desc": "選擇取得網站圖示的服務", + "favicon_service_scriptcat": "ScriptCat", + "favicon_service_local": "本地取得", + "favicon_service_google": "Google", "editor": { "show_script_list": "顯示腳本列表", "hide_script_list": "隱藏腳本列表" - } -} \ No newline at end of file + }, + "agent": "AI Agent", + "agent_chat": "會話", + "agent_provider": "模型服務", + "agent_mcp": "MCP", + "agent_skills": "Skills", + "agent_provider_title": "模型服務", + "agent_provider_select": "AI 服務提供商", + "agent_provider_api_base_url": "API 地址", + "agent_provider_api_key": "API 密鑰", + "agent_provider_model": "預設模型", + "agent_provider_test_connection": "測試連接", + "agent_provider_test_success": "連接成功", + "agent_provider_test_failed": "連接失敗", + "agent_model_fetch": "取得模型", + "agent_model_fetch_failed": "取得模型列表失敗", + "agent_model_name": "名稱", + "agent_model_add": "新增模型", + "agent_model_edit": "編輯", + "agent_model_delete": "刪除", + "agent_model_set_default": "設為預設", + "agent_model_default_label": "預設", + "agent_model_delete_confirm": "確定要刪除此模型配置嗎?", + "agent_model_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_delete_round": "刪除", + "agent_chat_copy_message": "複製", + "agent_chat_edit_message": "編輯", + "agent_chat_save_and_send": "儲存並傳送", + "agent_chat_cancel_edit": "取消", + "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": "參數" +} diff --git a/src/manifest.json b/src/manifest.json index 35a72068d..45800a971 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "__MSG_scriptcat__", - "version": "1.3.1", + "version": "1.5.0.1001", "author": "CodFrm", "description": "__MSG_scriptcat_description__", "options_ui": { @@ -10,9 +10,7 @@ }, "background": { "service_worker": "src/service_worker.js", - "scripts": [ - "src/service_worker.js" - ] + "scripts": ["src/service_worker.js"] }, "incognito": "split", "action": { @@ -41,28 +39,18 @@ "notifications", "clipboardWrite", "unlimitedStorage", - "declarativeNetRequest" - ], - "optional_permissions": [ - "background", - "userScripts" - ], - "host_permissions": [ - "" + "declarativeNetRequest", + "debugger" ], + "optional_permissions": ["background", "userScripts"], + "host_permissions": [""], "sandbox": { - "pages": [ - "src/sandbox.html" - ] + "pages": ["src/sandbox.html"] }, "web_accessible_resources": [ { - "resources": [ - "/src/install.html" - ], - "matches": [ - "" - ] + "resources": ["/src/install.html"], + "matches": [""] } ] -} \ No newline at end of file +} diff --git a/src/pages/batchupdate/index.css b/src/pages/batchupdate/index.css index 6954b0a75..ba648c31d 100644 --- a/src/pages/batchupdate/index.css +++ b/src/pages/batchupdate/index.css @@ -1,19 +1,19 @@ .batchupdate-mainlayout { - min-height: max-content; - padding-top: 12px; - padding-bottom: 12px; + min-height: max-content; + padding-top: 12px; + padding-bottom: 12px; } body .text-clickable { - color: inherit; - cursor: pointer; + color: inherit; + cursor: pointer; } body .text-clickable:hover { - color: inherit; - text-decoration: underline; + color: inherit; + text-decoration: underline; } .script-card.card-disabled { - filter: contrast(0.9); + filter: contrast(0.9); } diff --git a/src/pages/components/CloudScriptPlan/index.tsx b/src/pages/components/CloudScriptPlan/index.tsx index c74710b88..50fe8c930 100644 --- a/src/pages/components/CloudScriptPlan/index.tsx +++ b/src/pages/components/CloudScriptPlan/index.tsx @@ -57,6 +57,7 @@ const CloudScriptPlan: React.FC<{ } }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [script]); return ( { if (window.onbeforeunload) { - if (confirm(t("confirm_leave_page"))) { + // 目前仅用于 ScriptEditor 编辑内容修改提示 + if (confirm(t("script_modified_leave_confirm"))) { nav({ pathname: to, search, diff --git a/src/pages/components/FileSystemParams/index.tsx b/src/pages/components/FileSystemParams/index.tsx index 8e7549985..ebf74473a 100644 --- a/src/pages/components/FileSystemParams/index.tsx +++ b/src/pages/components/FileSystemParams/index.tsx @@ -1,9 +1,9 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { Button, Input, Message, Popconfirm, Select, Space } from "@arco-design/web-react"; import type { FileSystemType } from "@Packages/filesystem/factory"; import FileSystemFactory from "@Packages/filesystem/factory"; import { useTranslation } from "react-i18next"; -import { ClearNetDiskToken, netDiskTypeMap } from "@Packages/filesystem/auth"; +import { ClearNetDiskToken, HasNetDiskToken, netDiskTypeMap } from "@Packages/filesystem/auth"; const FileSystemParams: React.FC<{ headerContent: React.ReactNode | string; @@ -22,6 +22,17 @@ const FileSystemParams: React.FC<{ }) => { const fsParams = FileSystemFactory.params(); const { t } = useTranslation(); + const [hasBoundToken, setHasBoundToken] = useState(false); + + const netDiskType = netDiskTypeMap[fileSystemType]; + + useEffect(() => { + if (!netDiskType) { + setHasBoundToken(false); + return; + } + HasNetDiskToken(netDiskType).then(setHasBoundToken); + }, [netDiskType]); const fileSystemList: { key: FileSystemType; @@ -53,7 +64,6 @@ const FileSystemParams: React.FC<{ }, ]; - const netDiskType = netDiskTypeMap[fileSystemType]; const netDiskName = netDiskType ? fileSystemList.find((item) => item.key === fileSystemType)?.name : null; return ( @@ -74,13 +84,14 @@ const FileSystemParams: React.FC<{ ))} {children} - {netDiskType && netDiskName && ( + {netDiskType && netDiskName && hasBoundToken && ( { try { await ClearNetDiskToken(netDiskType); + setHasBoundToken(false); Message.success(t("netdisk_unbind_success", { provider: netDiskName })!); } catch (error) { Message.error(`${t("netdisk_unbind_error", { provider: netDiskName })}: ${String(error)}`); diff --git a/src/pages/components/PopupWarnings/index.tsx b/src/pages/components/PopupWarnings/index.tsx index 066170d27..8ff441a57 100644 --- a/src/pages/components/PopupWarnings/index.tsx +++ b/src/pages/components/PopupWarnings/index.tsx @@ -1,7 +1,7 @@ import { Alert, Button } from "@arco-design/web-react"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { checkUserScriptsAvailable, getBrowserType, BrowserType } from "@App/pkg/utils/utils"; +import { checkUserScriptsAvailable, getBrowserType, BrowserType, isPermissionOk } from "@App/pkg/utils/utils"; import edgeMobileQrCode from "@App/assets/images/edge_mobile_qrcode.png"; interface PopupWarningsProps { @@ -39,16 +39,28 @@ function PopupWarnings({ isBlacklist }: PopupWarningsProps) { const browser = browserType.chrome & BrowserType.Edge ? "edge" : "chrome"; - const warningMessageHTML = browserType.firefox - ? t("develop_mode_guide", { browser: "firefox" }) - : browserType.chrome - ? browserType.chrome & BrowserType.chromeA + let warningMessageHTML; + + if (browserType.firefox) { + // firefox + warningMessageHTML = t("develop_mode_guide", { browser: "firefox" }); + } else if (browserType.chrome) { + // chrome + warningMessageHTML = + browserType.chrome & BrowserType.noUserScriptsAPI ? t("lower_version_browser_guide") - : (browserType.chrome & BrowserType.chromeC && browserType.chrome & BrowserType.Chrome) || - browserType.chrome & BrowserType.edgeA - ? t("allow_user_script_guide", { browser }) // Edge 144+ 后使用`允许用户脚本`控制 - : t("develop_mode_guide", { browser }) // Edge浏览器目前没有允许用户脚本选项,开启开发者模式即可 - : "UNKNOWN"; + : // 120+ + browserType.chrome & BrowserType.guardedByDeveloperMode + ? t("develop_mode_guide", { browser }) // Edge浏览器目前没有允许用户脚本选项,开启开发者模式即可 + : // Edge 144+ / Chrome 138+ + browserType.chrome & BrowserType.guardedByAllowScript + ? t("allow_user_script_guide", { browser }) // Edge 144+ 后使用`允许用户脚本`控制 + : // 用于日后扩充更新版本 + "UNKNOWN"; + } else { + // other browsers + warningMessageHTML = "UNKNOWN"; + } return warningMessageHTML; }, [isUserScriptsAvailableState, t]); @@ -62,28 +74,14 @@ function PopupWarnings({ isBlacklist }: PopupWarningsProps) { // 权限要求详见:https://github.com/mdn/webextensions-examples/blob/main/userScripts-mv3/options.mjs useEffect(() => { - //@ts-ignore - if (chrome.permissions?.contains && chrome.permissions?.request) { - chrome.permissions.contains( - { - permissions: ["userScripts"], - }, - function (permissionOK) { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.permissions.contains:", lastError.message); - // runtime 错误的话不显示按钮 - return; - } - if (permissionOK === false) { - // 假设browser能支持 `chrome.permissions.contains` 及在 callback返回一个false值的话, - // chrome.permissions.request 应该可以执行 - // 因此在这裡显示按钮 - setShowRequestButton(true); - } - } - ); - } + isPermissionOk("userScripts").then((permissionOK) => { + if (permissionOK === false) { + // 假设browser能支持 `chrome.permissions.contains` 及在 callback返回一个false值的话, + // chrome.permissions.request 应该可以执行 + // 因此在这里显示按钮 + setShowRequestButton(true); + } + }); }, []); const handleRequestPermission = () => { @@ -103,8 +101,8 @@ function PopupWarnings({ isBlacklist }: PopupWarningsProps) { if (granted) { setPermissionReqResult("✅"); // UserScripts API相关的初始化: - // userScripts.LISTEN_CONNECTIONS 進行 Server 通讯初始化 - // onUserScriptAPIGrantAdded 進行 腳本注冊 + // userScripts.LISTEN_CONNECTIONS 进行 Server 通讯初始化 + // onUserScriptAPIGrantAdded 进行 脚本注册 updateIsUserScriptsAvailableState(); } else { setPermissionReqResult("❎"); diff --git a/src/pages/components/RuntimeSetting/index.tsx b/src/pages/components/RuntimeSetting/index.tsx index 60e7d2c7a..472d513c8 100644 --- a/src/pages/components/RuntimeSetting/index.tsx +++ b/src/pages/components/RuntimeSetting/index.tsx @@ -5,6 +5,7 @@ import FileSystemParams from "../FileSystemParams"; import { systemConfig } from "@App/pages/store/global"; import type { FileSystemType } from "@Packages/filesystem/factory"; import FileSystemFactory from "@Packages/filesystem/factory"; +import { isPermissionOk } from "@App/pkg/utils/utils"; const CollapseItem = Collapse.Item; @@ -24,11 +25,8 @@ const RuntimeSetting: React.FC = () => { setFilesystemType(res.filesystem); setFilesystemParam(res.params[res.filesystem] || {}); }); - chrome.permissions.contains({ permissions: ["background"] }, (result) => { - if (chrome.runtime.lastError) { - console.error(chrome.runtime.lastError); - return; - } + isPermissionOk("background").then((result) => { + if (result === null) return; // 无法要求 background permission setEnableBackgroundState(result); }); }, []); diff --git a/src/pages/components/ScriptSetting/Match.tsx b/src/pages/components/ScriptSetting/Match.tsx index a8ff7fedc..fe4f5224c 100644 --- a/src/pages/components/ScriptSetting/Match.tsx +++ b/src/pages/components/ScriptSetting/Match.tsx @@ -87,6 +87,7 @@ const Match: React.FC<{ useEffect(() => { refreshMatch(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [script]); const columns: ColumnProps[] = [ diff --git a/src/pages/components/layout/MainLayout.tsx b/src/pages/components/layout/MainLayout.tsx index e2c670fbc..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"; @@ -119,6 +120,15 @@ const importByUrls = async (urls: string[]): Promise => return stat; }; +const getSafePopupParent = (p: Element) => { + p = (p.closest("button")?.parentNode as Element) || p; // 確保 ancestor 沒有 button 元素 + p = (p.closest("span")?.parentNode as Element) || p; // 確保 ancestor 沒有 span 元素 + p = (p.closest(".arco-collapse-item-content")?.parentNode as Element) || p; // 確保 ancestor 沒有 .arco-collapse-item-content 元素 + p = (p.closest(".arco-card")?.parentNode as Element) || p; // 確保 ancestor 沒有 .arco-card 元素 + p = (p.closest("aside")?.parentNode as Element) || p; // 確保 ancestor 沒有 aside 元素 + return p; +}; + // --- 子组件:提取拖拽遮罩以优化性能 --- const DropzoneOverlay: React.FC<{ active: boolean; text: string }> = React.memo(({ active, text }) => { if (!active) return null; @@ -188,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传递过去 // 实现本地文件的监听 @@ -195,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; @@ -223,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") { @@ -250,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, @@ -297,20 +335,16 @@ const MainLayout: React.FC<{ componentConfig={{ Select: { getPopupContainer: (node) => { - return node; + return getSafePopupParent(node as Element); }, }, }} getPopupContainer={(node) => { - let p = node.parentNode as Element; - p = (p.closest("button")?.parentNode as Element) || p; // 確保 ancestor 沒有 button 元素 - p = (p.closest("span")?.parentNode as Element) || p; // 確保 ancestor 沒有 span 元素 - p = (p.closest("aside")?.parentNode as Element) || p; // 確保 ancestor 沒有 aside 元素 - return p; + return getSafePopupParent(node.parentNode as Element); }} > {contextHolder} - + diff --git a/src/pages/components/layout/Sider.tsx b/src/pages/components/layout/Sider.tsx index 37fd399ec..922e1b97e 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,40 @@ 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("logs")} @@ -171,7 +215,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", }} @@ -186,6 +230,7 @@ const Sider: React.FC = () => { } /> } /> } /> + } /> } />
diff --git a/src/pages/components/layout/index.css b/src/pages/components/layout/index.css index fd53b2633..75764a6af 100644 --- a/src/pages/components/layout/index.css +++ b/src/pages/components/layout/index.css @@ -1,26 +1,26 @@ .arco-dropdown-menu-selected { - background-color: var(--color-fill-2) !important; + background-color: var(--color-fill-2) !important; } .action-tools .arco-dropdown-popup-visible .arco-icon-down { - transform: rotate(180deg); + transform: rotate(180deg); } -.action-tools>.arco-btn { - padding: 0 8px; +.action-tools > .arco-btn { + padding: 0 8px; } .arco-dropdown-menu-item, .arco-dropdown-menu-item a { - display: flex; - align-items: center; + display: flex; + align-items: center; } :is(.arco-dropdown-menu-pop-header, .arco-dropdown-menu-item, .arco-dropdown-menu-item a) > svg { - margin-right: .5em; + margin-right: 0.5em; } /* 避免拖拽时 tooltip 等元件弹出 */ .dragzone-active .arco-layout-content { - pointer-events: none; + pointer-events: none; } diff --git a/src/pages/confirm/App.tsx b/src/pages/confirm/App.tsx index b440a33ad..7e7f32e9d 100644 --- a/src/pages/confirm/App.tsx +++ b/src/pages/confirm/App.tsx @@ -4,24 +4,29 @@ import React, { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { permissionClient } from "../store/features/script"; -function App() { - const uuid = new URLSearchParams(location.search).get("uuid"); +// 权限确认组件 +function PermissionConfirmRequest({ uuid }: { uuid: string }) { const [confirm, setConfirm] = React.useState(); const [likeNum, setLikeNum] = React.useState(0); const [second, setSecond] = React.useState(30); const { t } = useTranslation(); - if (second === 0) { - window.close(); - } - - setTimeout(() => { - setSecond(second - 1); - }, 1000); + useEffect(() => { + const timer = setInterval(() => { + setSecond((s) => { + if (s <= 1) { + clearInterval(timer); + window.close(); + return 0; + } + return s - 1; + }); + }, 1000); + return () => clearInterval(timer); + }, []); useEffect(() => { - if (!uuid) return; window.addEventListener("beforeunload", () => { permissionClient.confirm(uuid, { allow: false, @@ -39,11 +44,10 @@ function App() { .catch((e: any) => { Message.error(e.message || t("get_confirm_error")); }); - }, []); + }, [uuid, t]); const handleConfirm = (allow: boolean, type: number) => { return async () => { - if (!uuid) return; try { await permissionClient.confirm(uuid, { allow, @@ -143,4 +147,15 @@ function App() { ); } +function App() { + const params = new URLSearchParams(location.search); + const uuid = params.get("uuid"); + + if (uuid) { + return ; + } + + return null; +} + export default App; diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index 6bbb48d51..a01a396e0 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -1,751 +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 } 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: "text/javascript,application/javascript,text/plain,application/octet-stream,application/force-download", - // 参考:加权 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"); - } - if (response.headers.get("content-type")?.includes("text/html")) { - throw new Error("Response is text/html, not a valid UserScript"); - } - - 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") { - // 检查是否已经有后台权限 - if (!(await chrome.permissions.contains({ permissions: ["background"] }))) { - return true; - } - } - 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); - 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}
)}
@@ -759,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..7277fd514 --- /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/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..72cece6e3 --- /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/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/index.css b/src/pages/install/index.css index b54e8fd9b..820eac7e4 100644 --- a/src/pages/install/index.css +++ b/src/pages/install/index.css @@ -1,108 +1,123 @@ .monaco-diff-editor .diffOverview { - background: var(--vscode-editorGutter-background); + background: var(--vscode-editorGutter-background); } #install-app-container { - display: flex; - flex-direction: column; - min-height: calc(100vh - 50px); - box-sizing: border-box; + display: flex; + flex-direction: column; + min-height: calc(100vh - 50px); + box-sizing: border-box; } #show-code-container { - display: block; - height: calc( 100% - 44px ); - padding: 2px 2px; - position: relative; - box-sizing: border-box; - margin: 0; - border: 0; - flex-grow: 1; - flex-shrink: 0; - contain: strict; + display: block; + height: calc(100% - 44px); + padding: 2px 2px; + position: relative; + box-sizing: border-box; + margin: 0; + border: 0; + flex-grow: 1; + flex-shrink: 0; + contain: strict; } -#show-code { /* 配合 .sc-inset-0 */ - margin: 0px; - padding: 0px; - border: 0px; - overflow: hidden; - border: 1px solid var(--color-neutral-5); - box-sizing: border-box; - position: absolute; - background: #071119; +#show-code { + /* 配合 .sc-inset-0 */ + margin: 0px; + padding: 0px; + border: 0px; + overflow: hidden; + border: 1px solid var(--color-neutral-5); + box-sizing: border-box; + position: absolute; + background: #071119; } .downloading { - display: flex; - flex-direction: row; - flex-wrap: wrap; - column-gap: 4px; - align-items: center; + display: flex; + flex-direction: row; + flex-wrap: wrap; + column-gap: 4px; + align-items: center; } .error-message { - color: red; + color: red; } .error-message:empty { - display: none; + display: none; } .error-message::before { - content: "ERROR: "; + content: "ERROR: "; } /* https://css-loaders.com/dots/ */ .loader { - width: 60px; - aspect-ratio: 2; - --_g: no-repeat radial-gradient(circle closest-side, currentColor 90%, rgba(0,0,0,0)); - background: var(--_g) 0% 50%, var(--_g) 50% 50%, var(--_g) 100% 50%; - background-size: calc(100%/3) 50%; - animation: l3 1s infinite linear; - transform: scale(0.5); + width: 60px; + aspect-ratio: 2; + --_g: no-repeat radial-gradient(circle closest-side, currentColor 90%, rgba(0, 0, 0, 0)); + background: + var(--_g) 0% 50%, + var(--_g) 50% 50%, + var(--_g) 100% 50%; + background-size: calc(100% / 3) 50%; + animation: l3 1s infinite linear; + transform: scale(0.5); } @keyframes l3 { - 20% { - background-position: 0% 0%, 50% 50%, 100% 50% - } - - 40% { - background-position: 0% 100%, 50% 0%, 100% 50% - } - - 60% { - background-position: 0% 50%, 50% 100%, 100% 0% - } - - 80% { - background-position: 0% 50%, 50% 50%, 100% 100% - } - + 20% { + background-position: + 0% 0%, + 50% 50%, + 100% 50%; + } + + 40% { + background-position: + 0% 100%, + 50% 0%, + 100% 50%; + } + + 60% { + background-position: + 0% 50%, + 50% 100%, + 100% 0%; + } + + 80% { + background-position: + 0% 50%, + 50% 50%, + 100% 100%; + } } .tag-container { - inline-size: min-content; - align-content: flex-start; - justify-items: flex-end; - justify-content: flex-end; + inline-size: min-content; + align-content: flex-start; + justify-items: flex-end; + justify-content: flex-end; } .tag-container .arco-tag { - flex-grow: 1; - text-align: center; + flex-grow: 1; + text-align: center; } div.permission-entry span.arco-typography { - line-height: 1rem; - padding: 0; - margin: 0; + line-height: 1rem; + padding: 0; + margin: 0; } div.permission-entry { - line-height: 1.2rem; - padding: 0; - margin: 0; + line-height: 1.2rem; + padding: 0; + margin: 0; } diff --git a/src/pages/install/utils.ts b/src/pages/install/utils.ts new file mode 100644 index 000000000..7b1957432 --- /dev/null +++ b/src/pages/install/utils.ts @@ -0,0 +1,154 @@ +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: "text/javascript,application/javascript,text/plain,application/octet-stream,application/force-download", + // 参考:加权 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"); + } + if (response.headers.get("content-type")?.includes("text/html")) { + throw new Error("Response is text/html, not a valid UserScript"); + } + + 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/index.css b/src/pages/options/index.css index d7b98634f..492767d8c 100644 --- a/src/pages/options/index.css +++ b/src/pages/options/index.css @@ -79,7 +79,6 @@ h6.arco-typography { .arco-table-border .arco-table-th:first-child, .arco-table-border .arco-table-td:first-child { border-left: none !important; - } /* 卡片视图样式 */ @@ -107,5 +106,4 @@ h6.arco-typography { grid-template-columns: 1fr; } } - -} \ No newline at end of file +} diff --git a/src/pages/options/routes/Agent.tsx b/src/pages/options/routes/Agent.tsx new file mode 100644 index 000000000..2459fd699 --- /dev/null +++ b/src/pages/options/routes/Agent.tsx @@ -0,0 +1,23 @@ +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"; + +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..7aa0d36a1 --- /dev/null +++ b/src/pages/options/routes/AgentChat/AskUserBlock.tsx @@ -0,0 +1,58 @@ +import { useState, useRef, useEffect } from "react"; +import { Button, Input } from "@arco-design/web-react"; +import { IconSend } from "@arco-design/web-react/icon"; + +export default function AskUserBlock({ + id, + question, + onRespond, +}: { + id: string; + question: string; + onRespond: (id: string, answer: string) => void; +}) { + const [answer, setAnswer] = useState(""); + const [submitted, setSubmitted] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleSubmit = () => { + if (!answer.trim() || submitted) return; + setSubmitted(true); + onRespond(id, answer.trim()); + }; + + if (submitted) { + return ( +
+
Agent asked:
+
{question}
+
Your answer:
+
{answer}
+
+ ); + } + + return ( +
+
Agent is asking:
+
{question}
+
+ + +
+
+ ); +} diff --git a/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx b/src/pages/options/routes/AgentChat/AttachmentRenderers.tsx new file mode 100644 index 000000000..dc95178e3 --- /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/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..90e135aa4 --- /dev/null +++ b/src/pages/options/routes/AgentChat/ChatArea.tsx @@ -0,0 +1,445 @@ +import { useCallback, useEffect, useRef } 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/types"; +import { AgentChatRepo } from "@App/app/repo/agent_chat"; +import type { ChatMessage, ChatStreamEvent } from "@App/app/service/agent/types"; +import { getTextContent } from "@App/app/service/agent/content_utils"; +import { UserMessageItem, AssistantMessageGroup } from "./MessageItem"; +import ChatInput from "./ChatInput"; +import { useMessages, useStreamingChat, deleteMessages, clearMessages } from "./hooks"; +import AskUserBlock from "./AskUserBlock"; +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, +}: { + 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; +}) { + const { t } = useTranslation(); + const { messages, setMessages, loadMessages } = useMessages(conversationId); + const { isStreaming, sendMessage, stopGeneration, askUserPending, respondToAskUser } = useStreamingChat(); + const messagesEndRef = useRef(null); + const streamingMsgRef = useRef(null); + // 计时相关 + const sendStartTimeRef = useRef(0); + const firstTokenRecordedRef = useRef(false); + const firstTokenMsRef = useRef(undefined); + + // 自动滚动到底部 + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + useEffect(() => { + scrollToBottom(); + }, [messages, scrollToBottom]); + + // 流式期间累积的非文本 blocks(content_block_complete 事件) + const pendingBlocksRef = useRef([]); + + // 创建流式事件回调(提取公共逻辑) + const createStreamCallback = () => { + pendingBlocksRef.current = []; + return (event: ChatStreamEvent) => { + 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; + } + // 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.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": + case "sub_agent_event": + // 这些事件由 hook 层处理或仅作信息展示,不修改消息 + 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 "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 createDoneCallback = () => { + return async () => { + streamingMsgRef.current = null; + 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 + ); + }; + + // 用 ref 保存 startStreaming 的最新引用,避免 useCallback 闭包陈旧 + const startStreamingRef = useRef(startStreaming); + startStreamingRef.current = startStreaming; + + // 发送消息(支持附件文件或已有消息列表用于重新回答) + const handleSend = async (content: MessageContent, files?: Map) => { + if (!conversationId || !selectedModelId) return; + + // 处理 /new 命令:清空对话上下文 + if (typeof content === "string" && content.trim() === "/new") { + await clearMessages(conversationId); + setMessages([]); + return; + } + + // 保存附件到 OPFS + if (files && files.size > 0) { + for (const [id, file] of files) { + await chatRepo.saveAttachment(id, file); + } + } + + 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] + ); + + // 重新回答:删除当前 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); + setMessages(action.remainingMessages); + + // 通过 ref 调用最新的 startStreaming,避免闭包陈旧 + startStreamingRef.current(action.remainingMessages, action.userContent); + }, + [conversationId, isStreaming, messages, setMessages] + ); + + // 重新生成用户消息的回复:保留用户消息,只删除后续回复 + 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); + } + + setMessages(action.remainingMessages); + + // skipUserMessage=true:用户消息已在 remainingMessages 中,不需要重新创建 + startStreamingRef.current(action.remainingMessages, action.userContent, action.skipUserMessage); + }, + [conversationId, isStreaming, messages, setMessages] + ); + + // 删除整轮对话(用户消息 + 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, newContent: string) => { + if (isStreaming) return; + + const action = computeEditAction(messageId, messages); + if (!action) return; + + await deleteMessages(conversationId, action.idsToDelete); + setMessages(action.remainingMessages); + + // 通过 ref 调用最新的 startStreaming + startStreamingRef.current(action.remainingMessages, newContent); + }, + [conversationId, isStreaming, messages, setMessages] + ); + + // 只在模型加载完成后才判断是否无模型,避免加载中闪现提示 + 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, newContent)} + onRegenerate={ + findNextAssistantGroupIndex(messageGroups, groupIndex) != null || + groupIndex === messageGroups.length - 1 + ? () => handleRegenerateUserMessage(group.message.id) + : undefined + } + /> + ) : ( + handleCopy(group.messages)} + onRegenerate={() => handleRegenerate(messageGroups, groupIndex)} + onDelete={() => handleDeleteRound(messageGroups, groupIndex)} + /> + ) + ) + )} + {askUserPending && ( + + )} +
+
+
+ + {/* 输入区域 */} + + {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..84d9a7161 --- /dev/null +++ b/src/pages/options/routes/AgentChat/ChatInput.tsx @@ -0,0 +1,389 @@ +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 } from "@arco-design/web-react/icon"; +import { useTranslation } from "react-i18next"; +import type { AgentModelConfig, SkillSummary, MessageContent, ContentBlock } from "@App/app/service/agent/types"; +import { groupModelsByProvider, supportsVision, supportsImageOutput } from "./model_utils"; +import ProviderIcon from "./ProviderIcon"; + +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, +}: { + 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; +}) { + const { t } = useTranslation(); + const [input, setInput] = useState(""); + const [attachments, setAttachments] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + + // 自动调整高度 + 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) => URL.revokeObjectURL(a.previewUrl)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const addImageFiles = useCallback((files: File[]) => { + const imageFiles = files.filter((f) => f.type.startsWith("image/")); + if (imageFiles.length === 0) return; + const newAttachments = imageFiles.map((file) => ({ + id: `att_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + file, + previewUrl: URL.createObjectURL(file), + })); + setAttachments((prev) => [...prev, ...newAttachments]); + }, []); + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => { + const att = prev.find((a) => a.id === id); + if (att) URL.revokeObjectURL(att.previewUrl); + return prev.filter((a) => a.id !== id); + }); + }, []); + + const handleSend = () => { + const trimmed = input.trim(); + if ((!trimmed && attachments.length === 0) || isStreaming || disabled) 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) { + blocks.push({ type: "image", attachmentId: att.id, mimeType: att.file.type, name: att.file.name }); + files.set(att.id, att.file); + } + + onSend(blocks, files); + // 清理(不 revoke,发送后由调用方负责) + setAttachments([]); + } else { + onSend(trimmed); + } + setInput(""); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // 忽略输入法组合状态中的回车(如中文输入法确认候选词) + if (e.nativeEvent.isComposing) 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].type.startsWith("image/")) { + const file = items[i].getAsFile(); + if (file) files.push(file); + } + } + if (files.length > 0) { + e.preventDefault(); + addImageFiles(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); + addImageFiles(files); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + addImageFiles(files); + // reset input value so the same file can be selected again + e.target.value = ""; + }; + + const canSend = (input.trim() || attachments.length > 0) && !disabled; + + return ( +
+
+
+ {/* 附件预览条 */} + {attachments.length > 0 && ( +
+ {attachments.map((att) => ( +
+ {att.file.name} + +
+ ))} +
+ )} + + {/* 输入区域 */} +
+