Skip to content

Commit 6050d3c

Browse files
committed
✨ 新增 Bing/百度搜索引擎 & Agent 设置页面
- 扩展 SearchEngineConfig 支持 bing/baidu,默认引擎改为 Bing - web_search 新增 searchBing/searchBaidu,html_extractor 新增对应解析方法 - 新增摘要模型配置(getSummaryModelId/setSummaryModelId),summarizeContent 优先使用摘要模型 - 新建 AgentSettings 页面:摘要模型选择 + 搜索引擎配置 - 侧边栏/路由注册、8 个 locale 文件新增 i18n keys - 新增 Bing/百度搜索测试用例
1 parent 1bffca0 commit 6050d3c

23 files changed

Lines changed: 536 additions & 32 deletions

src/app/repo/agent_model.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { AgentModelConfig } from "@App/app/service/agent/types";
22
import { Repo, loadCache } from "./repo";
33

44
const DEFAULT_MODEL_KEY = "agent_model:__default__";
5+
const SUMMARY_MODEL_KEY = "agent_model:__summary__";
56

67
// 使用 chrome.storage.local 存储 Agent 模型配置
78
export class AgentModelRepo extends Repo<AgentModelConfig> {
@@ -10,9 +11,9 @@ export class AgentModelRepo extends Repo<AgentModelConfig> {
1011
this.enableCache();
1112
}
1213

13-
// 获取所有模型(排除 __default__ 这个存储默认模型 ID 的 key)
14+
// 获取所有模型(排除 __default__ / __summary__ 等内部 key)
1415
async listModels(): Promise<AgentModelConfig[]> {
15-
return this.find((key) => key !== `${this.prefix}__default__`);
16+
return this.find((key) => !key.startsWith(`${this.prefix}__`));
1617
}
1718

1819
// 获取指定模型
@@ -46,4 +47,21 @@ export class AgentModelRepo extends Repo<AgentModelConfig> {
4647
});
4748
});
4849
}
50+
51+
// 获取摘要模型 ID
52+
async getSummaryModelId(): Promise<string> {
53+
const cache = await loadCache();
54+
return (cache[SUMMARY_MODEL_KEY] as string) || "";
55+
}
56+
57+
// 设置摘要模型 ID
58+
async setSummaryModelId(id: string): Promise<void> {
59+
const cache = await loadCache();
60+
cache[SUMMARY_MODEL_KEY] = id;
61+
return new Promise<void>((resolve) => {
62+
chrome.storage.local.set({ [SUMMARY_MODEL_KEY]: id }, () => {
63+
resolve();
64+
});
65+
});
66+
}
4967
}

src/app/service/agent/system_prompt.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,19 @@ Detect when you are stuck and stop early:
4444
- Keep responses concise — do not over-explain routine operations.
4545
- When reporting extracted data or results, format them clearly (use lists or structured text).
4646
47-
## Built-in Tools
48-
49-
You have direct access to these tools (no skill loading needed):
50-
- **web_fetch**: Fetch and extract content from a URL (HTML auto-extracted to readable text, JSON returned directly). Use for reading web pages, APIs, or downloading text content.
51-
- **web_search**: Search the web for information. Returns results with title, URL, and snippet.
52-
- **ask_user**: Ask the user a question and wait for their response. Use when you need clarification or a decision.
53-
- **agent**: Spawn a sub-agent for complex independent subtasks. The sub-agent has its own context and can use web_fetch, web_search, task tools, skills, and MCP tools. It cannot interact with the user directly.
54-
- **create_task / get_task / update_task / list_tasks**: Track multi-step work within this conversation. Tasks are in-memory only (not persisted across conversations).`;
47+
## Tool Selection Guide
48+
49+
- **Read page content** → prefer \`get_tab_content\` (structured markdown) over \`execute_script\` (raw JS).
50+
- **Fetch remote data** → \`web_fetch\` for text/HTML/JSON. It does NOT support binary downloads — use a SkillScript with \`fetch()\` + \`CAT.agent.opfs.write(blob)\` for binary files.
51+
- **Ask user** → \`ask_user\` supports text only. To show images to the user, use \`execute_script\` to display them on page.
52+
53+
## Binary File Workflow
54+
55+
OPFS workspace stores files persistently. Binary files (images, PDFs, etc.) should stay as file references — never put large binary data in your messages.
56+
57+
**Save**: screenshot with \`saveTo\` / SkillScript \`fetch()\` → \`CAT.agent.opfs.write(blob)\` → returns path
58+
**Use**: \`opfs_read(path, format='bloburl')\` → returns \`blob:chrome-extension://\` URL → pass to \`execute_script(target='page', world='ISOLATED')\` which can \`fetch()\` the blob URL and manipulate page DOM
59+
**Note**: Blob URLs are scoped to the extension origin. Only ISOLATED world (or Offscreen) can access them — MAIN world cannot.`;
5560

5661
// Skill 摘要提示词模板
5762
export const SKILL_SUFFIX_HEADER = `---

src/app/service/agent/tools/execute_script.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import type { ToolExecutor } from "@App/app/service/agent/tool_registry";
44
export const EXECUTE_SCRIPT_DEFINITION: ToolDefinition = {
55
name: "execute_script",
66
description:
7-
"Execute JavaScript code. Use target='page' to run in a web page (DOM access). Use target='sandbox' for isolated computation.",
7+
"Execute JavaScript code. " +
8+
"target='page': run in a browser tab with DOM access. world param (page only): " +
9+
"ISOLATED (default) — extension-isolated context, can fetch extension blob URLs (blob:chrome-extension://...) AND manipulate page DOM, ideal for bridging OPFS files to page operations; " +
10+
"MAIN — shares page's window/globals (access page JS variables, call page functions), but cannot access extension URLs. " +
11+
"target='sandbox': isolated computation environment, no DOM, no world param.",
812
parameters: {
913
type: "object",
1014
properties: {

src/app/service/agent/tools/opfs_tools.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export { sanitizePath };
1515

1616
const OPFS_WRITE_DEFINITION: ToolDefinition = {
1717
name: "opfs_write",
18-
description: "Write text content to a file in the workspace. Creates parent directories automatically.",
18+
description:
19+
"Write content to a file in the workspace. Supports text strings, Blob, and data URL (base64 auto-decoded to binary). Creates parent directories automatically.",
1920
parameters: {
2021
type: "object",
2122
properties: {

src/app/service/agent/tools/search_config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
export type SearchEngineConfig = {
2-
engine: "duckduckgo" | "google_custom";
2+
engine: "bing" | "duckduckgo" | "baidu" | "google_custom";
33
googleApiKey?: string;
44
googleCseId?: string;
55
};
66

77
const STORAGE_KEY = "agent_search_config";
88

99
const DEFAULT_CONFIG: SearchEngineConfig = {
10-
engine: "duckduckgo",
10+
engine: "bing",
1111
};
1212

1313
export class SearchConfigRepo {

src/app/service/agent/tools/web_fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { extractHtmlContent } from "@App/app/service/offscreen/client";
66
export const WEB_FETCH_DEFINITION: ToolDefinition = {
77
name: "web_fetch",
88
description:
9-
"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. " +
9+
"Fetch content from a URL. Returns extracted text for HTML pages, raw content for JSON/plain text. Text only — not suitable for binary downloads. " +
1010
"Use prompt to have the LLM summarize/extract specific information from the fetched content.",
1111
parameters: {
1212
type: "object",

src/app/service/agent/tools/web_search.test.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe("WebSearchExecutor", () => {
1010
sendMessage: vi.fn().mockImplementation(() => Promise.resolve({ data: mockExtractReturnValue })),
1111
} as any;
1212

13-
const createMockConfigRepo = (engine: "duckduckgo" | "google_custom"): SearchConfigRepo => ({
13+
const createMockConfigRepo = (engine: "bing" | "duckduckgo" | "baidu" | "google_custom"): SearchConfigRepo => ({
1414
getConfig: vi.fn().mockResolvedValue({
1515
engine,
1616
googleApiKey: engine === "google_custom" ? "test-key" : undefined,
@@ -208,6 +208,83 @@ describe("WebSearchExecutor", () => {
208208
expect(result).toEqual([]);
209209
});
210210

211+
it("should search Bing and return results", async () => {
212+
const mockFetch = vi.fn().mockResolvedValue({
213+
ok: true,
214+
text: () =>
215+
Promise.resolve(
216+
"<html><li class='b_algo'><h2><a href='https://example.com'>Bing Result</a></h2><div class='b_caption'><p>Bing snippet</p></div></li></html>"
217+
),
218+
});
219+
vi.stubGlobal("fetch", mockFetch);
220+
221+
mockExtractReturnValue = [{ title: "Bing Result", url: "https://example.com", snippet: "Bing snippet" }];
222+
223+
const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("bing"));
224+
const result = JSON.parse((await executor.execute({ query: "bing test" })) as string);
225+
226+
expect(result).toHaveLength(1);
227+
expect(result[0].title).toBe("Bing Result");
228+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("bing.com/search"), expect.any(Object));
229+
});
230+
231+
it("should throw when Bing returns error", async () => {
232+
const mockFetch = vi.fn().mockResolvedValue({
233+
ok: false,
234+
status: 503,
235+
});
236+
vi.stubGlobal("fetch", mockFetch);
237+
238+
const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("bing"));
239+
await expect(executor.execute({ query: "test" })).rejects.toThrow("Bing search failed");
240+
});
241+
242+
it("should return empty array when Bing extraction fails", async () => {
243+
const mockFetch = vi.fn().mockResolvedValue({
244+
ok: true,
245+
text: () => Promise.resolve("<html></html>"),
246+
});
247+
vi.stubGlobal("fetch", mockFetch);
248+
249+
mockSender.sendMessage.mockImplementation(() => Promise.reject(new Error("extract timeout")));
250+
251+
const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("bing"));
252+
const result = JSON.parse((await executor.execute({ query: "test" })) as string);
253+
254+
expect(result).toEqual([]);
255+
});
256+
257+
it("should search Baidu and return results", async () => {
258+
const mockFetch = vi.fn().mockResolvedValue({
259+
ok: true,
260+
text: () =>
261+
Promise.resolve(
262+
"<html><div class='result'><h3 class='t'><a href='https://example.com'>Baidu Result</a></h3><div class='c-abstract'>Baidu snippet</div></div></html>"
263+
),
264+
});
265+
vi.stubGlobal("fetch", mockFetch);
266+
267+
mockExtractReturnValue = [{ title: "Baidu Result", url: "https://example.com", snippet: "Baidu snippet" }];
268+
269+
const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("baidu"));
270+
const result = JSON.parse((await executor.execute({ query: "百度测试" })) as string);
271+
272+
expect(result).toHaveLength(1);
273+
expect(result[0].title).toBe("Baidu Result");
274+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("baidu.com/s"), expect.any(Object));
275+
});
276+
277+
it("should throw when Baidu returns error", async () => {
278+
const mockFetch = vi.fn().mockResolvedValue({
279+
ok: false,
280+
status: 503,
281+
});
282+
vi.stubGlobal("fetch", mockFetch);
283+
284+
const executor = new WebSearchExecutor(mockSender, createMockConfigRepo("baidu"));
285+
await expect(executor.execute({ query: "test" })).rejects.toThrow("Baidu search failed");
286+
});
287+
211288
it("should default to 5 results when max_results not specified", async () => {
212289
const mockFetch = vi.fn().mockResolvedValue({
213290
ok: true,

src/app/service/agent/tools/web_search.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ToolDefinition } from "@App/app/service/agent/types";
22
import type { ToolExecutor } from "@App/app/service/agent/tool_registry";
33
import type { MessageSend } from "@Packages/message/types";
44
import type { SearchConfigRepo } from "./search_config";
5-
import { extractSearchResults } from "@App/app/service/offscreen/client";
5+
import { extractSearchResults, extractBingResults, extractBaiduResults } from "@App/app/service/offscreen/client";
66

77
export const WEB_SEARCH_DEFINITION: ToolDefinition = {
88
name: "web_search",
@@ -38,8 +38,12 @@ export class WebSearchExecutor implements ToolExecutor {
3838
case "google_custom":
3939
return this.searchGoogle(query, maxResults, config.googleApiKey || "", config.googleCseId || "");
4040
case "duckduckgo":
41-
default:
4241
return this.searchDuckDuckGo(query, maxResults);
42+
case "baidu":
43+
return this.searchBaidu(query, maxResults);
44+
case "bing":
45+
default:
46+
return this.searchBing(query, maxResults);
4347
}
4448
}
4549

@@ -73,6 +77,62 @@ export class WebSearchExecutor implements ToolExecutor {
7377
return JSON.stringify(results.slice(0, maxResults));
7478
}
7579

80+
private async searchBing(query: string, maxResults: number): Promise<string> {
81+
const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${maxResults}`;
82+
const response = await fetch(url, {
83+
headers: {
84+
"User-Agent": "Mozilla/5.0 (compatible; ScriptCat Agent)",
85+
},
86+
signal: AbortSignal.timeout(15_000),
87+
});
88+
89+
if (!response.ok) {
90+
throw new Error(`Bing search failed: HTTP ${response.status}`);
91+
}
92+
93+
const html = await response.text();
94+
95+
let results: Awaited<ReturnType<typeof extractBingResults>>;
96+
try {
97+
results = await Promise.race([
98+
extractBingResults(this.sender, html),
99+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("extract timeout")), 10_000)),
100+
]);
101+
} catch {
102+
results = [];
103+
}
104+
105+
return JSON.stringify(results.slice(0, maxResults));
106+
}
107+
108+
private async searchBaidu(query: string, maxResults: number): Promise<string> {
109+
const url = `https://www.baidu.com/s?wd=${encodeURIComponent(query)}&rn=${maxResults}`;
110+
const response = await fetch(url, {
111+
headers: {
112+
"User-Agent": "Mozilla/5.0 (compatible; ScriptCat Agent)",
113+
},
114+
signal: AbortSignal.timeout(15_000),
115+
});
116+
117+
if (!response.ok) {
118+
throw new Error(`Baidu search failed: HTTP ${response.status}`);
119+
}
120+
121+
const html = await response.text();
122+
123+
let results: Awaited<ReturnType<typeof extractBaiduResults>>;
124+
try {
125+
results = await Promise.race([
126+
extractBaiduResults(this.sender, html),
127+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("extract timeout")), 10_000)),
128+
]);
129+
} catch {
130+
results = [];
131+
}
132+
133+
return JSON.stringify(results.slice(0, maxResults));
134+
}
135+
76136
private async searchGoogle(query: string, maxResults: number, apiKey: string, cseId: string): Promise<string> {
77137
if (!apiKey || !cseId) {
78138
throw new Error("Google Custom Search requires API Key and CSE ID. Configure them in Agent Tool Settings.");

src/app/service/offscreen/client.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@ export async function extractHtmlWithSelectors(msgSender: MessageSend, html: str
6565
return result ?? null;
6666
}
6767

68+
// Bing 搜索结果提取
69+
export async function extractBingResults(
70+
msgSender: MessageSend,
71+
html: string
72+
): Promise<Array<{ title: string; url: string; snippet: string }>> {
73+
const result = await sendMessage(msgSender, "offscreen/htmlExtractor/extractBingResults", html);
74+
return result ?? [];
75+
}
76+
77+
// 百度搜索结果提取
78+
export async function extractBaiduResults(
79+
msgSender: MessageSend,
80+
html: string
81+
): Promise<Array<{ title: string; url: string; snippet: string }>> {
82+
const result = await sendMessage(msgSender, "offscreen/htmlExtractor/extractBaiduResults", html);
83+
return result ?? [];
84+
}
85+
6886
// 搜索结果提取
6987
export async function extractSearchResults(
7088
msgSender: MessageSend,

src/app/service/offscreen/html_extractor.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export class HtmlExtractorService {
1313
this.group.on("extractHtmlContent", (html: string) => this.extractHtmlContent(html));
1414
this.group.on("extractHtmlWithSelectors", (html: string) => this.extractHtmlWithSelectors(html));
1515
this.group.on("extractSearchResults", (html: string) => this.extractSearchResults(html));
16+
this.group.on("extractBingResults", (html: string) => this.extractBingResults(html));
17+
this.group.on("extractBaiduResults", (html: string) => this.extractBaiduResults(html));
1618
}
1719

1820
extractHtmlContent(html: string): string | null {
@@ -299,6 +301,62 @@ export class HtmlExtractorService {
299301
}
300302
}
301303

304+
// 解析 Bing 搜索结果
305+
extractBingResults(html: string): SearchResult[] {
306+
try {
307+
const parser = new DOMParser();
308+
const doc = parser.parseFromString(html, "text/html");
309+
const results: SearchResult[] = [];
310+
311+
const resultEls = doc.querySelectorAll(".b_algo");
312+
for (const el of Array.from(resultEls)) {
313+
const linkEl = el.querySelector("h2 > a");
314+
const snippetEl = el.querySelector(".b_caption p, p");
315+
if (!linkEl) continue;
316+
317+
const title = (linkEl.textContent || "").trim();
318+
const url = linkEl.getAttribute("href") || "";
319+
const snippet = (snippetEl?.textContent || "").trim();
320+
321+
if (title && url) {
322+
results.push({ title, url, snippet });
323+
}
324+
}
325+
326+
return results;
327+
} catch {
328+
return [];
329+
}
330+
}
331+
332+
// 解析百度搜索结果
333+
extractBaiduResults(html: string): SearchResult[] {
334+
try {
335+
const parser = new DOMParser();
336+
const doc = parser.parseFromString(html, "text/html");
337+
const results: SearchResult[] = [];
338+
339+
const resultEls = doc.querySelectorAll(".result, .result-op");
340+
for (const el of Array.from(resultEls)) {
341+
const linkEl = el.querySelector(".t > a, h3 > a");
342+
const snippetEl = el.querySelector(".c-abstract, .c-span-last");
343+
if (!linkEl) continue;
344+
345+
const title = (linkEl.textContent || "").trim();
346+
const url = linkEl.getAttribute("href") || "";
347+
const snippet = (snippetEl?.textContent || "").trim();
348+
349+
if (title && url) {
350+
results.push({ title, url, snippet });
351+
}
352+
}
353+
354+
return results;
355+
} catch {
356+
return [];
357+
}
358+
}
359+
302360
extractSearchResults(html: string): SearchResult[] {
303361
try {
304362
const parser = new DOMParser();

0 commit comments

Comments
 (0)