Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
278 changes: 275 additions & 3 deletions .tests/cindy-web-search.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ function createHarness(options = {}) {
cindy,
fetch: async (url, init) => {
if (options.kvFetch) return options.kvFetch(url, init);
if (url === '/secrets') {
return {
ok: true,
async json() {
return options.secrets ?? [];
},
};
}
assert.equal(url, '/kv');
return {
ok: true,
Expand Down Expand Up @@ -183,15 +191,15 @@ function createSettingsHarness(options = {}) {
}

test('manifest declares Cindy Web Search and keeps BYO providers explicit', () => {
assert.equal(manifest.version, '1.4.0');
assert.equal(manifest.version, '1.5.0');
assert.equal(manifest.minCindyVersion, '0.1.64');
assert.equal(manifest.schemaVersion, 3);
assert.deepEqual(manifest.cindy, { search: ['web'] });
assert.equal(Object.hasOwn(manifest, 'slots'), false);
assert.deepEqual(manifest.network.hosts, ['api.search.brave.com', 'api.tavily.com', 'api.deepseek.com']);
assert.deepEqual(manifest.network.hosts, ['api.search.brave.com', 'api.tavily.com', 'api.deepseek.com', 'api.anysearch.com']);
assert.deepEqual(manifest.setup, { requires: [] });
const provider = manifest.tools[0].parameters.properties.provider;
assert.deepEqual(provider.enum, ['cindy', 'brave', 'tavily', 'deepseek']);
assert.deepEqual(provider.enum, ['cindy', 'brave', 'tavily', 'deepseek', 'anysearch']);
assert.equal(provider.enum.includes('auto'), false);
const query = manifest.tools[0].parameters.properties.query;
assert.equal(query.maxLength, 2000);
Expand Down Expand Up @@ -631,3 +639,267 @@ test('settings load and persist DeepSeek as the BYO default without losing other
assert.deepEqual(JSON.parse(write.init.body), original);
assert.match(harness.status.textContent, /已保存/);
});

function anySearchResponse(results = []) {
return {
ok: true,
status: 200,
body: JSON.stringify({
code: 0,
message: 'success',
request_id: 'test-request-id',
data: { results, metadata: { total_results: results.length, search_time_ms: 12 } },
}),
};
}

function anySearchItem(overrides = {}) {
return {
title: 'Example page', url: 'https://example.test/page',
snippet: 'Short snippet', content: 'Cleaned content', ...overrides,
};
}

test('AnySearch posts a trimmed query with the shared 1–10 limit and maps snippet/content', async () => {
const harness = createHarness({
kv: { byoDefaultProvider: 'anysearch' },
networkResult: () => anySearchResponse([
anySearchItem(), anySearchItem({ snippet: '', content: 'Content fallback' }),
anySearchItem({ title: 'Extra result' }),
]),
});
const result = await harness.search({ provider: 'anysearch', query: ' Go 1.26 release notes ', limit: 2 });
assert.equal(result.ok, true);
assert.equal(result.result.provider, 'anysearch');
assert.deepEqual(JSON.parse(JSON.stringify(result.result.results)), [
{ title: 'Example page', url: 'https://example.test/page', snippet: 'Short snippet' },
{ title: 'Example page', url: 'https://example.test/page', snippet: 'Content fallback' },
]);
assert.equal(harness.networkCalls.length, 1);
assert.equal(harness.cindyRequests.length, 0);
assert.equal(harness.toolResults.length, 1);
const request = harness.networkCalls[0];
assert.equal(request.url, 'https://api.anysearch.com/v1/search');
assert.equal(request.method, 'POST');
assert.equal(request.callId, 'call-1');
assert.deepEqual(JSON.parse(JSON.stringify(request.headers)), { 'Content-Type': 'application/json' });
assert.deepEqual(JSON.parse(request.body), { query: 'Go 1.26 release notes', max_results: 2 });
});

test('AnySearch may be the BYO default while Cindy AI remains the initial default', async () => {
const configured = { cindyAiEnabled: false, byoDefaultProvider: 'anysearch' };
const harness = createHarness({ kv: configured, networkResult: () => anySearchResponse() });
const result = await harness.search();
assert.equal(result.ok, true);
assert.equal(result.result.provider, 'anysearch');
assert.equal(JSON.parse(harness.networkCalls[0].body).max_results, 5);
assert.equal(harness.cindyRequests.length, 0);

const enabled = createHarness({ kv: { ...configured, cindyAiEnabled: true } });
assert.equal((await enabled.search()).result.provider, 'cindy');
assert.equal(enabled.networkCalls.length, 0);
});

test('AnySearch limit respects the shared 1–10 result contract', async () => {
for (const [limit, count] of [[0, 1], [2.9, 2], [50, 10]]) {
const harness = createHarness({ kv: { byoDefaultProvider: 'anysearch' }, networkResult: () => anySearchResponse() });
assert.equal((await harness.search({ provider: 'anysearch', limit })).ok, true);
assert.equal(JSON.parse(harness.networkCalls[0].body).max_results, count);
}
});

for (const [status, code, guidance] of [
[400, 'INVALID_INPUT', /查询/], [401, 'AUTH_REJECTED', /更新 Key/],
[402, 'QUOTA_EXHAUSTED', /Key/], [403, 'PERMISSION_DENIED', /授权/],
[415, 'INVALID_INPUT', /参数/], [429, 'RATE_LIMITED', /稍后重试/],
[500, 'UPSTREAM_UNAVAILABLE', /稍后重试/], [502, 'UPSTREAM_UNAVAILABLE', /稍后重试/],
[301, 'UPSTREAM_REJECTED', /权限/], [418, 'UPSTREAM_REJECTED', /权限/],
]) {
test(`AnySearch HTTP ${status} gives actionable ${code} without retry, fallback or body disclosure`, async () => {
const harness = createHarness({ kv: { byoDefaultProvider: 'anysearch' }, networkResult: () => ({ ok: true, status, body: 'upstream-private-detail' }) });
const result = await harness.search({ provider: 'anysearch' });
assert.equal(result.ok, false);
assert.equal(result.errorCode, code);
assert.match(result.message, guidance);
assert.doesNotMatch(result.message, /upstream-private-detail|HTTP|\b[345]\d\d\b/);
assert.equal(harness.networkCalls.length, 1);
assert.equal(harness.cindyRequests.length, 0);
assert.equal(harness.toolResults.length, 1);
});
}

test('AnySearch 402 with auto-generated credentials never leaks them to the user', async () => {
const harness = createHarness({
kv: { byoDefaultProvider: 'anysearch' },
networkResult: () => ({
ok: true,
status: 402,
body: JSON.stringify({
code: -1,
message: 'Your account and API key have been automatically generated.\nusername=u123\npassword=p456\napi_key=as_sk_abcdef',
request_id: 'r1',
}),
}),
});
const result = await harness.search({ provider: 'anysearch' });
assert.equal(result.ok, false);
assert.equal(result.errorCode, 'QUOTA_EXHAUSTED');
assert.doesNotMatch(result.message, /u123|p456|as_sk_abcdef|username|password|api_key/);
assert.match(result.message, /Key/);
});

test('AnySearch preserves structured Host error codes with safe, actionable messages', async () => {
for (const [errorCode, guidance] of [
['AUTH_REJECTED', /更新 Key/], ['PERMISSION_DENIED', /授权/],
['QUOTA_EXHAUSTED', /Key/], ['RATE_LIMITED', /稍后重试/],
['INVALID_INPUT', /查询/], ['UPSTREAM_UNAVAILABLE', /网络连接/],
]) {
for (const throws of [false, true]) {
const harness = createHarness({ kv: { byoDefaultProvider: 'anysearch' }, networkResult: () => {
if (throws) throw Object.assign(new Error('private-host-detail'), { errorCode });
return { ok: false, errorCode, message: 'private-host-detail' };
} });
const result = await harness.search({ provider: 'anysearch' });
assert.equal(result.errorCode, errorCode);
assert.match(result.message, guidance);
assert.doesNotMatch(result.message, /private-host-detail/);
assert.equal(harness.networkCalls.length, 1);
assert.equal(harness.cindyRequests.length, 0);
assert.equal(harness.toolResults.length, 1);
}
}
});

test('AnySearch handles legacy Host failures and exceptions without exposing diagnostics', async () => {
for (const failure of [null, { ok: false, message: 'private-host-detail' }, new Error('private-host-detail')]) {
const harness = createHarness({ kv: { byoDefaultProvider: 'anysearch' }, networkResult: () => {
if (failure instanceof Error) throw failure;
return failure;
} });
const result = await harness.search({ provider: 'anysearch' });
assert.equal(result.ok, false);
assert.equal(result.errorCode, 'INTERNAL');
assert.match(result.message, /网络连接/);
assert.match(result.message, /AnySearch/);
assert.doesNotMatch(result.message, /private-host-detail/);
assert.equal(harness.networkCalls.length, 1);
}
});

test('AnySearch rejects malformed, truncated and non-zero-code responses instead of returning empty success', async () => {
const bodies = ['invalid-json', 'null', '{}',
JSON.stringify({ code: 0, data: {} }),
JSON.stringify({ code: 0, data: { results: {} } }),
JSON.stringify({ code: -1, message: 'Query is required.', data: { results: [] } }),
JSON.stringify({ code: 0, data: { results: [null] } }),
JSON.stringify({ code: 0, data: { results: [{ title: null, url: 'u' }] } }),
JSON.stringify({ code: 0, data: { results: [{ title: 't', url: 42 }] } }),
' '.repeat(1000001), null,
];
for (const response of [
...bodies.map(body => ({ ok: true, status: 200, body })),
{ ...anySearchResponse(), truncated: true },
]) {
const harness = createHarness({ kv: { byoDefaultProvider: 'anysearch' }, networkResult: () => response });
const result = await harness.search({ provider: 'anysearch' });
assert.equal(result.ok, false);
assert.equal(result.errorCode, 'RESPONSE_INVALID');
assert.equal(harness.networkCalls.length, 1);
assert.equal(harness.cindyRequests.length, 0);
}
});

test('AnySearch coerces non-string snippet/content instead of returning objects', async () => {
const harness = createHarness({
kv: { byoDefaultProvider: 'anysearch' },
networkResult: () => anySearchResponse([
anySearchItem({ snippet: {}, content: 'text' }),
anySearchItem({ snippet: 'ok', content: 42 }),
anySearchItem({ snippet: null, content: '' }),
]),
});
const result = await harness.search({ provider: 'anysearch', limit: 3 });
assert.equal(result.ok, true);
assert.deepEqual(JSON.parse(JSON.stringify(result.result.results)), [
{ title: 'Example page', url: 'https://example.test/page', snippet: 'text' },
{ title: 'Example page', url: 'https://example.test/page', snippet: 'ok' },
{ title: 'Example page', url: 'https://example.test/page', snippet: '' },
]);
assert.equal(harness.networkCalls.length, 1);
});

test('AnySearch credentials are declared only for the API host and have a settings row', () => {
const secret = manifest.network.secrets.find(item => item.key === 'anysearch_api_key');
assert.deepEqual(secret.inject, {
header: 'Authorization', format: 'Bearer {value}', hosts: ['api.anysearch.com'],
});
assert.equal(secret.url, 'https://anysearch.com/console/api-keys');
assert.equal(manifest.network.hosts.includes('api.anysearch.com'), true);
assert.match(settingsHtml, /data-key="anysearch_api_key"/);
assert.match(settingsHtml, /href="https:\/\/anysearch\.com\/console\/api-keys"/);
assert.match(settingsHtml, /<option value="anysearch">AnySearch<\/option>/);
for (const locale of locales) {
assert.match(locale.description, /AnySearch/);
assert.match(locale.whenToUse, /AnySearch/);
}
});

test('settings load and persist AnySearch as the BYO default without losing other preferences', async () => {
const original = { cindyAiEnabled: false, byoDefaultProvider: 'anysearch', unrelated: 'keep' };
const harness = createSettingsHarness({ readKv: async () => ({ ok: true, json: async () => original }) });
await flushAsync();
assert.equal(harness.byoDefaultProvider.value, 'anysearch');
harness.byoDefaultProvider.emit('change');
await flushAsync();
const write = harness.fetchCalls.find(call => call.init.method === 'PUT');
assert.deepEqual(JSON.parse(write.init.body), original);
assert.match(harness.status.textContent, /已保存/);
});

test('AnySearch anonymous calls fail closed with NOT_CONFIGURED until the user opts in', async () => {
// No AnySearch preference, no saved key -> no network request at all.
const neverOptedIn = createHarness({ kv: {}, networkResult: () => anySearchResponse() });
const blocked = await neverOptedIn.search({ provider: 'anysearch' });
assert.equal(blocked.ok, false);
assert.equal(blocked.errorCode, 'NOT_CONFIGURED');
assert.match(blocked.message, /AnySearch/);
assert.equal(neverOptedIn.networkCalls.length, 0);
assert.equal(neverOptedIn.cindyRequests.length, 0);
assert.equal(neverOptedIn.toolResults.length, 1);

// Explicit provider still requires the same opt-in.
const stillBlocked = createHarness({ kv: { cindyAiEnabled: true }, networkResult: () => anySearchResponse() });
const blockedAgain = await stillBlocked.search({ provider: 'anysearch' });
assert.equal(blockedAgain.ok, false);
assert.equal(blockedAgain.errorCode, 'NOT_CONFIGURED');
assert.equal(stillBlocked.networkCalls.length, 0);

// Saving an AnySearch API Key opts the user in even without the preference.
const keyOnly = createHarness({
kv: {},
secrets: [{ key: 'anysearch_api_key', saved: true, tail: 'abcd' }],
networkResult: () => anySearchResponse(),
});
const keyResult = await keyOnly.search({ provider: 'anysearch' });
assert.equal(keyResult.ok, true);
assert.equal(keyResult.result.provider, 'anysearch');
assert.equal(keyOnly.networkCalls.length, 1);

// An unrelated saved key does not opt in.
const unrelatedKey = createHarness({
kv: {},
secrets: [{ key: 'tavily_api_key', saved: true }],
networkResult: () => anySearchResponse(),
});
const unrelatedResult = await unrelatedKey.search({ provider: 'anysearch' });
assert.equal(unrelatedResult.ok, false);
assert.equal(unrelatedResult.errorCode, 'NOT_CONFIGURED');
assert.equal(unrelatedKey.networkCalls.length, 0);

// Preference read failures fail closed.
const unreadable = createHarness({ kvFetch: async () => { throw new Error('kv unavailable'); } });
const unreadableResult = await unreadable.search({ provider: 'anysearch' });
assert.equal(unreadableResult.ok, false);
assert.equal(unreadableResult.errorCode, 'NOT_CONFIGURED');
assert.equal(unreadable.networkCalls.length, 0);
});
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": 3,
"id": "cindy-web-search",
"name": "Web Search",
"description": "Cindy 内置的网页搜索插件:默认使用 Cindy AI 搜索,也可用你自己的 Brave / Tavily / DeepSeek key 搜索公网。key 在主界面侧边栏「插件」→「Web Search」详情页填写,加密存本机、只注入声明域名的请求。",
"whenToUse": "需要搜索公网信息、查资料、找网页、看最新动态时找我(默认使用 Cindy AI 搜索,也可在主界面侧边栏「插件」→「Web Search」详情页配置 Brave、Tavily 或 DeepSeek key)。",
"version": "1.4.0",
"description": "Cindy 内置的网页搜索插件:默认使用 Cindy AI 搜索,也可用你自己的 Brave / Tavily / DeepSeek / AnySearch key 搜索公网。key 在主界面侧边栏「插件」→「Web Search」详情页填写,加密存本机、只注入声明域名的请求。AnySearch 可不填 key 匿名使用。",
"whenToUse": "需要搜索公网信息、查资料、找网页、看最新动态时找我(默认使用 Cindy AI 搜索,也可在主界面侧边栏「插件」→「Web Search」详情页配置 Brave、Tavily、DeepSeek 或 AnySearch key)。",
"version": "1.5.0",
"minCindyVersion": "0.1.64",
"locales": {
"en": "locales/en.json",
Expand All @@ -21,7 +21,7 @@
"search": ["web"]
},
"network": {
"hosts": ["api.search.brave.com", "api.tavily.com", "api.deepseek.com"],
"hosts": ["api.search.brave.com", "api.tavily.com", "api.deepseek.com", "api.anysearch.com"],
"secrets": [
{
"key": "brave_api_key",
Expand Down Expand Up @@ -55,6 +55,17 @@
"format": "Bearer {value}",
"hosts": ["api.deepseek.com"]
}
},
{
"key": "anysearch_api_key",
"label": "AnySearch API Key",
"hint": "可选。免 Key 匿名搜索需先在上方「自备搜索渠道」选择 AnySearch,或填写本 Key(as_sk_ 开头)按账号额度计费",
"url": "https://anysearch.com/console/api-keys",
"inject": {
"header": "Authorization",
"format": "Bearer {value}",
"hosts": ["api.anysearch.com"]
}
}
]
},
Expand All @@ -64,12 +75,12 @@
"tools": [
{
"name": "search_web",
"description": "搜索公网,返回 { provider, results: [{ title, url, snippet }] }。默认 Cindy AI,也可指定 Brave / Tavily / DeepSeek。查询最多 2000 字符;DeepSeek 最多 128 个 Unicode 字符(不截断),摘要优先使用 summary,按成功请求计费,不读取全文。失败时保留宿主 errorCode,把 message 告诉用户;缺 Key 时按指引在插件详情页配置。不自动重试或切换搜索源。",
"description": "搜索公网,返回 { provider, results: [{ title, url, snippet }] }。默认 Cindy AI,也可指定 Brave / Tavily / DeepSeek / AnySearch。查询最多 2000 字符;DeepSeek 最多 128 个 Unicode 字符(不截断),摘要优先使用 summary,按成功请求计费;AnySearch 可免 Key 匿名搜索(国内可访问、速率较低),但需先在「插件」→「Web Search」详情页把 AnySearch 设为自备搜索渠道(关闭 Cindy AI 后生效)或填写 AnySearch API Key,否则返回 NOT_CONFIGURED;snippet 缺失时用 content 回退,不读取全文。失败时保留宿主 errorCode,把 message 告诉用户;缺 Key 时按指引在插件详情页配置。不自动重试或切换搜索源。",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "maxLength": 2000, "description": "搜索关键词(保留用户意图,不要扩写,最多 2000 字符;DeepSeek 最多 128 个 Unicode 字符)" },
"provider": { "type": "string", "enum": ["cindy", "brave", "tavily", "deepseek"], "description": "指定搜索源;通常省略(默认使用 Cindy AI 搜索)" },
"provider": { "type": "string", "enum": ["cindy", "brave", "tavily", "deepseek", "anysearch"], "description": "指定搜索源;通常省略(默认使用 Cindy AI 搜索)" },
"limit": { "type": "number", "description": "结果条数,默认 5,最大 10" }
},
"required": ["query"]
Expand Down
Loading
Loading