Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions .tests/cindy-web-search.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const pluginDir = path.join(repoRoot, 'cindy-web-search');
const source = fs.readFileSync(path.join(pluginDir, 'main.js'), 'utf8');

async function invoke(args, fetchImpl) {
let hostHandler = null;
let result = null;
const requests = [];
const context = {
cindy: {
async fetch(request) {
requests.push(request);
return fetchImpl(request, requests.length - 1);
},
onHostMessage(handler) {
hostHandler = handler;
},
send(message) {
result = message;
},
},
};

vm.runInNewContext(source, context, { filename: 'main.js' });
assert.equal(typeof hostHandler, 'function');
await hostHandler({ type: 'tool-call', tool: 'search_web', callId: 'test-call', args });
return { requests, result };
}

test('Search1API request keeps the shared tool contract and maps link to url', async () => {
const { requests, result } = await invoke(
{ query: 'cindy plugins', provider: 'search1api', limit: 3 },
async () => ({
ok: true,
status: 200,
body: JSON.stringify({
results: [{ title: 'Cindy', link: 'https://example.test/cindy', snippet: 'Plugin docs' }],
}),
}),
);

assert.equal(requests.length, 1);
assert.equal(requests[0].url, 'https://api.search1api.com/search');
assert.equal(requests[0].method, 'POST');
assert.deepEqual(JSON.parse(requests[0].body), {
query: 'cindy plugins',
max_results: 3,
crawl_results: 0,
});
assert.equal(requests[0].headers.Authorization, undefined, 'credential must be host-injected');
assert.equal(JSON.parse(requests[0].body).search_service, undefined, 'use Search1API default source selection');
assert.deepEqual(JSON.parse(JSON.stringify(result.result.results)), [
{ title: 'Cindy', url: 'https://example.test/cindy', snippet: 'Plugin docs' },
]);
assert.equal(result.result.provider, 'search1api');
});

test('missing Brave and Tavily keys fall back to Search1API', async () => {
const { requests, result } = await invoke({ query: 'fallback', limit: 2 }, async (_request, index) => {
if (index < 2) return { ok: false, message: '凭证尚未配置,请前往插件详情页填写' };
return { ok: true, status: 200, body: JSON.stringify({ results: [] }) };
});

assert.deepEqual(requests.map((request) => new URL(request.url).hostname), [
'api.search.brave.com',
'api.tavily.com',
'api.search1api.com',
]);
assert.equal(result.ok, true);
assert.equal(result.result.provider, 'search1api');
});

test('Search1API 404 is a successful empty search', async () => {
const { result } = await invoke(
{ query: 'no results', provider: 'search1api' },
async () => ({ ok: true, status: 404, body: '{"message":"not found"}' }),
);

assert.equal(result.ok, true);
assert.equal(result.result.provider, 'search1api');
assert.equal(result.result.results.length, 0);
});

for (const [status, expected] of [
[401, /插件详情页.*Key/],
[402, /credits.*控制台/],
[403, /账号权限或套餐/],
[429, /稍后再试/],
[500, /服务暂时不可用/],
]) {
test(`Search1API ${status} returns an actionable error`, async () => {
const { result } = await invoke(
{ query: 'error', provider: 'search1api' },
async () => ({ ok: true, status, body: '{"message":"upstream"}' }),
);

assert.equal(result.ok, false);
assert.match(result.message, expected);
assert.doesNotMatch(result.message, /upstream/);
});
}

test('Search1API malformed and unexpected responses fail without leaking raw bodies', async (t) => {
await t.test('malformed JSON', async () => {
const { result } = await invoke(
{ query: 'bad json', provider: 'search1api' },
async () => ({ ok: true, status: 200, body: '<html>upstream secret detail</html>' }),
);
assert.equal(result.ok, false);
assert.match(result.message, /无法解析/);
assert.doesNotMatch(result.message, /secret detail/);
});

await t.test('missing results array', async () => {
const { result } = await invoke(
{ query: 'bad shape', provider: 'search1api' },
async () => ({ ok: true, status: 200, body: '{"results":null}' }),
);
assert.equal(result.ok, false);
assert.match(result.message, /结果格式不符合预期/);
});
});

test('manifest uses minimal Search1API host injection and the settings link matches exactly', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, 'ghost.json'), 'utf8'));
const settingsHtml = fs.readFileSync(path.join(pluginDir, 'settings.html'), 'utf8');
const secret = manifest.network.secrets.find((item) => item.key === 'search1api_api_key');

assert.deepEqual(secret.inject, {
header: 'Authorization',
format: 'Bearer {value}',
hosts: ['api.search1api.com'],
});
assert.ok(manifest.network.hosts.includes('api.search1api.com'));
assert.ok(settingsHtml.includes(`href="${secret.url}"`));
});
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ This is the source for every official plugin (Ghost) in the
| <img src="./cindy-gitlab/assets/icon.png" width="22" alt=""> | GitLab | [`cindy-gitlab`](./cindy-gitlab) | GitLab (gitlab.com and self-hosted) issues / MRs / repository operations |
| <img src="./cindy-mermaid/assets/icon.jpg" width="22" alt=""> | Mermaid | [`cindy-mermaid`](./cindy-mermaid) | Mermaid diagram source normalization and common syntax fixes |
| <img src="./cindy-notion/assets/icon.png" width="22" alt=""> | Notion | [`cindy-notion`](./cindy-notion) | Read/write Notion pages, databases, and knowledge bases |
| <img src="./cindy-web-search/assets/icon.png" width="22" alt=""> | Web Search | [`cindy-web-search`](./cindy-web-search) | Public web search (Brave / Tavily, user-provided API key) |
| <img src="./cindy-web-search/assets/icon.png" width="22" alt=""> | Web Search | [`cindy-web-search`](./cindy-web-search) | Public web search (Brave / Tavily / Search1API, user-provided API key) |
| <img src="./world-bank-open-data/assets/icon.png" width="22" alt=""> | World Bank Open Data | [`world-bank-open-data`](./world-bank-open-data) | Public country, economic, social, and development indicators with no API key; staged rollout |
| <img src="./google-gmail/assets/icon.png" width="22" alt=""> | Gmail | [`google-gmail`](./google-gmail) | Search, read, and organize Gmail, create drafts, and send messages; host-managed OAuth |
| <img src="./google-drive/assets/icon.png" width="22" alt=""> | Google Drive | [`google-drive`](./google-drive) | Search, read, download, upload, move, and delete Drive files |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
| <img src="./cindy-gitlab/assets/icon.png" width="22" alt=""> | GitLab | [`cindy-gitlab`](./cindy-gitlab) | GitLab(gitlab.com 及自建实例)issue / MR / 仓库操作 |
| <img src="./cindy-mermaid/assets/icon.jpg" width="22" alt=""> | Mermaid | [`cindy-mermaid`](./cindy-mermaid) | Mermaid 图表源码规范化与常见语法修复 |
| <img src="./cindy-notion/assets/icon.png" width="22" alt=""> | Notion | [`cindy-notion`](./cindy-notion) | Notion 页面、数据库与知识库读写 |
| <img src="./cindy-web-search/assets/icon.png" width="22" alt=""> | Web Search | [`cindy-web-search`](./cindy-web-search) | 公网搜索(Brave / Tavily,用户自备 API key) |
| <img src="./cindy-web-search/assets/icon.png" width="22" alt=""> | Web Search | [`cindy-web-search`](./cindy-web-search) | 公网搜索(Brave / Tavily / Search1API,用户自备 API key) |
| <img src="./world-bank-open-data/assets/icon.png" width="22" alt=""> | 世界银行公开数据 | [`world-bank-open-data`](./world-bank-open-data) | 无需 API Key,查询全球国家、经济、社会与发展指标;定向灰度 |
| <img src="./google-gmail/assets/icon.png" width="22" alt=""> | Gmail | [`google-gmail`](./google-gmail) | 搜索、阅读、整理 Gmail 邮件,生成草稿或发送邮件;授权由宿主托管 |
| <img src="./google-drive/assets/icon.png" width="22" alt=""> | Google Drive | [`google-drive`](./google-drive) | 搜索、读取、下载、上传、移动和删除云端文件 |
Expand Down
23 changes: 17 additions & 6 deletions cindy-web-search/ghost.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"schemaVersion": 2,
"id": "cindy-web-search",
"name": "Web Search",
"description": "Cindy 内置的网页搜索插件:用你自己的 Brave / Tavily key 搜索公网。key 在主界面侧边栏「插件」→「Web Search」详情页填写,加密存本机、只注入声明域名的请求。",
"whenToUse": "需要搜索公网信息、查资料、找网页、看最新动态时找我(需先在主界面侧边栏「插件」→「Web Search」详情页配置 Brave 或 Tavily key)。",
"version": "1.2.2",
"description": "Cindy 内置的网页搜索插件:用你自己的 Brave / Tavily / Search1API key 搜索公网。key 在主界面侧边栏「插件」→「Web Search」详情页填写,加密存本机、只注入声明域名的请求。",
"whenToUse": "需要搜索公网信息、查资料、找网页、看最新动态时找我(需先在主界面侧边栏「插件」→「Web Search」详情页配置 Brave、TavilySearch1API key)。",
"version": "1.3.0",
"locales": {
"en": "locales/en.json",
"zh-CN": "locales/zh-CN.json",
Expand All @@ -18,7 +18,7 @@
"slots": ["tool", "network"],
"command": "cindy-web-search",
"network": {
"hosts": ["api.search.brave.com", "api.tavily.com"],
"hosts": ["api.search.brave.com", "api.tavily.com", "api.search1api.com"],
"secrets": [
{
"key": "brave_api_key",
Expand All @@ -41,18 +41,29 @@
"format": "Bearer {value}",
"hosts": ["api.tavily.com"]
}
},
{
"key": "search1api_api_key",
"label": "Search1API API Key",
"hint": "在控制台注册后获取",
"url": "https://dashboard.search1api.com/api-keys",
"inject": {
"header": "Authorization",
"format": "Bearer {value}",
"hosts": ["api.search1api.com"]
}
}
]
},
"tools": [
{
"name": "search_web",
"description": "搜索公网并返回结果列表(标题/链接/摘要)。默认走 Brave;需要网页正文摘录或指定域名过滤时走 Tavily。返回 JSON:{ provider, results: [{ title, url, snippet }] }。两个搜索源的 key 都没配置时会返回指引,把 message 原样告诉用户即可。",
"description": "搜索公网并返回结果列表(标题/链接/摘要)。provider 可指定 brave、tavily 或 search1api;省略时按 BraveTavily → Search1API 顺序使用首个已配置 Key 的搜索源。返回 JSON:{ provider, results: [{ title, url, snippet }] }。所有搜索源的 Key 都没配置时会返回指引,把 message 原样告诉用户即可。",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "搜索关键词(用户原话意图,不要扩写)" },
"provider": { "type": "string", "enum": ["brave", "tavily"], "description": "指定搜索源;通常省略(自动选可用的)" },
"provider": { "type": "string", "enum": ["brave", "tavily", "search1api"], "description": "指定搜索源;省略时按 Brave → Tavily → Search1API 顺序使用首个已配置 Key 的源" },
"limit": { "type": "number", "description": "结果条数,默认 5,最大 10" }
},
"required": ["query"]
Expand Down
6 changes: 3 additions & 3 deletions cindy-web-search/locales/en.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "Web Search",
"description": "Cindy's built-in web search plugin. Search the public web with your own Brave or Tavily API key. Keys are configured in the plugin details, encrypted locally, and injected only into requests to declared hosts.",
"whenToUse": "Use this plugin to search the public web, research a topic, find web pages, or check current information. Configure a Brave or Tavily key in the Web Search plugin details first.",
"description": "Cindy's built-in web search plugin. Search the public web with your own Brave, Tavily, or Search1API key. Keys are configured in the plugin details, encrypted locally, and injected only into requests to declared hosts.",
"whenToUse": "Use this plugin to search the public web, research a topic, find web pages, or check current information. Configure a Brave, Tavily, or Search1API key in the Web Search plugin details first.",
"tools": {
"search_web": {
"description": "Search the public web and return a list of { title, url, snippet } results. Brave is used by default; use Tavily when page-content extracts or domain filters are needed. Returns { provider, results }. If neither search key is configured, return the provided message to the user unchanged."
"description": "Search the public web and return a list of { title, url, snippet } results. Set provider to brave, tavily, or search1api; when omitted, use the first source with a configured key in Brave → Tavily → Search1API order. Returns { provider, results }. If no search key is configured, return the provided message to the user unchanged."
}
}
}
6 changes: 3 additions & 3 deletions cindy-web-search/locales/ja.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "Web Search",
"description": "Cindy 内蔵のウェブ検索プラグインです。自分の Brave または Tavily API キーで公開ウェブを検索します。キーはプラグイン詳細で設定し、ローカルで暗号化され、宣言済みホストへのリクエストにだけ注入されます。",
"whenToUse": "公開ウェブの検索、調査、ウェブページの発見、最新情報の確認に使用します。先に Web Search プラグイン詳細で Brave または Tavily のキーを設定してください。",
"description": "Cindy 内蔵のウェブ検索プラグインです。自分の Brave、Tavily、または Search1API の API キーで公開ウェブを検索します。キーはプラグイン詳細で設定し、ローカルで暗号化され、宣言済みホストへのリクエストにだけ注入されます。",
"whenToUse": "公開ウェブの検索、調査、ウェブページの発見、最新情報の確認に使用します。先に Web Search プラグイン詳細で Brave、Tavily、または Search1API のキーを設定してください。",
"tools": {
"search_web": {
"description": "公開ウェブを検索し、{ title, url, snippet } の結果一覧を返します。既定では Brave を使い、ページ本文の抜粋やドメイン指定が必要な場合は Tavily を使います。{ provider, results } を返します。どちらの検索キーも未設定の場合は、返された message を変更せずユーザーに伝えてください。"
"description": "公開ウェブを検索し、{ title, url, snippet } の結果一覧を返します。provider には brave、tavily、search1api を指定できます。省略した場合は Brave → Tavily → Search1API の順で、キーが設定済みの最初の検索元を使用します。{ provider, results } を返します。どの検索キーも未設定の場合は、返された message を変更せずユーザーに伝えてください。"
}
}
}
6 changes: 3 additions & 3 deletions cindy-web-search/locales/ko.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "Web Search",
"description": "Cindy에 내장된 웹 검색 플러그인입니다. 사용자의 Brave 또는 Tavily API 키로 공개 웹을 검색합니다. 키는 플러그인 상세 화면에서 설정하며 로컬에서 암호화되고 선언된 호스트 요청에만 주입됩니다.",
"whenToUse": "공개 웹 검색, 자료 조사, 웹페이지 찾기, 최신 정보 확인에 사용합니다. 먼저 Web Search 플러그인 상세 화면에서 Brave 또는 Tavily 키를 설정하세요.",
"description": "Cindy에 내장된 웹 검색 플러그인입니다. 사용자의 Brave, Tavily 또는 Search1API 키로 공개 웹을 검색합니다. 키는 플러그인 상세 화면에서 설정하며 로컬에서 암호화되고 선언된 호스트 요청에만 주입됩니다.",
"whenToUse": "공개 웹 검색, 자료 조사, 웹페이지 찾기, 최신 정보 확인에 사용합니다. 먼저 Web Search 플러그인 상세 화면에서 Brave, Tavily 또는 Search1API 키를 설정하세요.",
"tools": {
"search_web": {
"description": "공개 웹을 검색해 { title, url, snippet } 결과 목록을 반환합니다. 기본값은 Brave이며 페이지 본문 발췌나 도메인 필터가 필요하면 Tavily를 사용합니다. { provider, results }를 반환합니다. 검색 키가 모두 설정되지 않았다면 반환된 message를 그대로 사용자에게 전달하세요."
"description": "공개 웹을 검색해 { title, url, snippet } 결과 목록을 반환합니다. provider로 brave, tavily 또는 search1api를 지정할 수 있습니다. 생략하면 Brave → Tavily → Search1API 순서로 키가 설정된 첫 번째 검색 소스를 사용합니다. { provider, results }를 반환합니다. 검색 키가 하나도 설정되지 않았다면 반환된 message를 그대로 사용자에게 전달하세요."
}
}
}
6 changes: 3 additions & 3 deletions cindy-web-search/locales/zh-CN.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "Web Search",
"description": "Cindy 内置的网页搜索插件:使用你自己的 Brave 或 Tavily Key 搜索公网。Key 在插件详情页填写,在本机加密保存,并且只注入发往已声明域名的请求。",
"whenToUse": "需要搜索公网信息、查资料、找网页或查看最新动态时使用。请先在 Web Search 插件详情页配置 Brave 或 Tavily Key。",
"description": "Cindy 内置的网页搜索插件:使用你自己的 Brave、TavilySearch1API Key 搜索公网。Key 在插件详情页填写,在本机加密保存,并且只注入发往已声明域名的请求。",
"whenToUse": "需要搜索公网信息、查资料、找网页或查看最新动态时使用。请先在 Web Search 插件详情页配置 Brave、TavilySearch1API Key。",
"tools": {
"search_web": {
"description": "搜索公网并返回 { title, url, snippet } 结果列表。默认使用 Brave;需要网页正文摘录或指定域名过滤时使用 Tavily。返回 { provider, results }。两个搜索源的 Key 都没配置时,把返回的 message 原样告诉用户。"
"description": "搜索公网并返回 { title, url, snippet } 结果列表。provider 可指定 brave、tavily 或 search1api;省略时按 Brave → Tavily → Search1API 顺序使用首个已配置 Key 的搜索源。返回 { provider, results }。所有搜索源的 Key 都没配置时,把返回的 message 原样告诉用户。"
}
}
}
Loading