Files
openmaic/OpenMAIC/lib/web-search/doubao.ts
2026-08-16 14:58:47 +08:00

123 lines
3.7 KiB
TypeScript

/**
* Doubao Web Search integration (豆包搜索 Custom 版).
*
* The Volcengine Ark Agent Plan exposes web search through the same REST
* endpoint the `mcp-server-askecho-search-infinity` MCP server wraps:
* POST https://open.feedcoopapi.com/search_api/web_search
* authenticated with the Agent Plan's dedicated API Key (Bearer). We call it
* directly so it lights up as a one-click token-plan modality, no MCP runtime.
*
* Docs: https://www.volcengine.com/docs/85508/1650263
*/
import { proxyFetch } from '@/lib/server/proxy-fetch';
import type { WebSearchResult, WebSearchSource } from '@/lib/types/web-search';
const DOUBAO_DEFAULT_BASE_URL = 'https://open.feedcoopapi.com';
const DOUBAO_SEARCH_PATH = '/search_api/web_search';
/** Query is truncated past 100 chars by the API; trim defensively first. */
const DOUBAO_MAX_QUERY_LENGTH = 100;
function buildDoubaoSearchUrl(baseUrl?: string): string {
const trimmed = (baseUrl || DOUBAO_DEFAULT_BASE_URL).replace(/\/$/, '');
return trimmed.endsWith(DOUBAO_SEARCH_PATH) ? trimmed : `${trimmed}${DOUBAO_SEARCH_PATH}`;
}
/** Doubao reports failures in ResponseMetadata.Error, often with HTTP 200. */
interface DoubaoError {
Code?: string;
CodeN?: number;
Message?: string;
}
interface DoubaoWebItem {
Title?: string;
Url?: string;
Snippet?: string;
Summary?: string;
Content?: string;
SiteName?: string;
RankScore?: number;
PublishTime?: string;
}
interface DoubaoSearchResponse {
ResponseMetadata?: {
RequestId?: string;
Error?: DoubaoError;
};
Result?: {
ResultCount?: number;
WebResults?: DoubaoWebItem[] | null;
} | null;
}
function formatDoubaoError(
err: DoubaoError | undefined,
status: number,
statusText: string,
): string {
const code = err?.Code ?? err?.CodeN ?? status;
const message = err?.Message || statusText || 'Request failed';
return `Doubao Web Search API error (${code}): ${message}`;
}
/**
* Search the web using Doubao Search (Custom 版) and return structured results.
* Prefers `Summary` (query-relevant 500~1000 char excerpt, recommended for LLM
* use) over `Snippet` (~200 char list blurb) for each source's content.
*/
export async function searchWithDoubao(params: {
query: string;
apiKey: string;
maxResults?: number;
baseUrl?: string;
}): Promise<WebSearchResult> {
const { query, apiKey, maxResults = 10, baseUrl } = params;
const startedAt = Date.now();
const limit = Math.max(Math.floor(maxResults), 1);
const res = await proxyFetch(buildDoubaoSearchUrl(baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
Query: query.slice(0, DOUBAO_MAX_QUERY_LENGTH),
SearchType: 'web',
Count: Math.min(limit, 50),
NeedSummary: true,
}),
});
if (!res.ok) {
const errorText = await res.text().catch(() => '');
throw new Error(`Doubao Web Search API error (${res.status}): ${errorText || res.statusText}`);
}
const raw = (await res.json()) as DoubaoSearchResponse;
const err = raw.ResponseMetadata?.Error;
// Doubao returns errors inside a 200 body; CodeN 0 / absent Error == success.
if (err && (err.Code || err.CodeN || err.Message)) {
throw new Error(formatDoubaoError(err, res.status, res.statusText));
}
const sources: WebSearchSource[] = (raw.Result?.WebResults || [])
.map((item) => ({
title: item.Title || item.Url || '',
url: item.Url || '',
content: item.Summary || item.Content || item.Snippet || '',
score: typeof item.RankScore === 'number' ? item.RankScore : 0,
}))
.filter((source) => source.url)
.slice(0, limit);
return {
answer: '',
sources,
query,
responseTime: (Date.now() - startedAt) / 1000,
};
}