Skip to content
Open
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ BYTEPLUS_VIDEO_MODEL=seedance-1-5-pro-251215
# Firecrawl web-browser integration — server-only; never VITE_
# Can also come from shell export (e.g. ~/.claude/secrets/search-apis.env)
FIRECRAWL_API_KEY=
# Basic web_search / web_browser: empty uses Firecrawl when configured, otherwise
# free anonymous Parallel Search MCP. Override with parallel, firecrawl or disabled.
WEB_SEARCH_PROVIDER=

# Social publishing (publish_to_social) via Upload-Post — server-only; never VITE_
# API key and profile from https://app.upload-post.com (connect your accounts to the profile)
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,26 @@ Third-party components and assets remain subject to their respective licenses.
---


## Web research in the built-in agent

`web_search` and basic `web_browser` page reading work without a search account
or API key through [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp).
Search uses Fast mode and returns source URLs, titles and relevant excerpts.
Page reading returns markdown excerpts, rather than full-page HTML or screenshots.
Queries and requested URLs are sent to the selected provider. Anonymous access
has service quotas; provider errors are returned without switching providers.

An existing `FIRECRAWL_API_KEY` in Settings or the environment keeps Firecrawl as
the default. Set `WEB_SEARCH_PROVIDER` in `.env.local` (then restart) to `parallel`
or `firecrawl` to choose explicitly, or `disabled` to disable basic search and
page reading. A tool call can select `provider: "parallel"` or `"firecrawl"` unless
the server setting is `disabled`. Firecrawl remains available for search locale
and time filters, screenshots, browser actions, structured extraction, full-page
formats, mapping, crawling and batch scraping; those features require its key.

Related search and page-reading calls reuse an opaque conversation ID. Clearing
the chat starts a new ID; the project ID and chat text are never used as that ID.

## Contributing

1. Create a branch from `main`.
Expand Down
16 changes: 16 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,22 @@ OpenChatCut 采用 [GNU Affero General Public License v3.0 或更高版本](LICE

---

## 内置 Agent 的网页检索

`web_search` 和基础 `web_browser` 阅读可通过
[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp)
直接使用,无需搜索服务账号或 API Key。搜索使用 Fast 模式,返回来源 URL、标题和相关摘录;
网页阅读返回 Markdown 摘录,不提供完整 HTML 或截图。查询和目标 URL 会发送给选定的服务商。
匿名服务有配额限制,服务商报错时不会自动切换到其他服务商。

已在设置或环境中配置的 `FIRECRAWL_API_KEY` 会保留 Firecrawl 默认行为。
可在 `.env.local` 中设置 `WEB_SEARCH_PROVIDER` 为 `parallel`、`firecrawl` 或 `disabled`
(修改后重启);`disabled` 会关闭基础搜索与网页阅读。
工具调用也可通过 `provider: "parallel"` 或 `"firecrawl"` 显式选择,但不能绕过 `disabled`。
搜索地区/语言/时间过滤、截图、浏览器动作、结构化提取、完整页面格式、站点映射、爬取和批量抓取
仍需 Firecrawl Key。相关搜索与网页阅读共用不透明的会话 ID;清空聊天后生成新 ID,
不会使用工程 ID 或聊天文本作为会话 ID。

## 贡献

1. 从 `main` 创建分支。
Expand Down
24 changes: 20 additions & 4 deletions assets/agent/openchatcut-tool-schemas.json
Original file line number Diff line number Diff line change
Expand Up @@ -3732,10 +3732,18 @@
},
{
"name": "web_browser",
"description": "Scrape a single web page via Firecrawl (web_browser → Firecrawl /scrape). formats: markdown (default), html, rawHtml, images, links, branding, summary, screenshot, videos. branding = native brand kit (colors/fonts/logo); summary = native page summary. screenshot is auto-saved to media pool as screenshotAssetId when possible. For many known URLs use web_batch_scrape; site discovery use web_map; multi-page crawl use web_crawl; search use web_search.",
"description": "Read a web page: Parallel excerpts without a key, or Firecrawl when its key is configured. Parallel supports markdown excerpts and an optional query; advanced formats/actions require provider=firecrawl. formats: markdown (default), html, rawHtml, images, links, branding, summary, screenshot, videos. branding = native brand kit (colors/fonts/logo); summary = native page summary. screenshot is auto-saved to media pool as screenshotAssetId when possible. For many known URLs use web_batch_scrape; site discovery use web_map; multi-page crawl use web_crawl; search use web_search.",
"input_schema": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"parallel",
"firecrawl"
],
"description": "Override the server default. Firecrawl requires a configured key."
},
"url": {
"type": "string",
"description": "Page URL (http/https)."
Expand Down Expand Up @@ -3780,7 +3788,7 @@
},
"query": {
"type": "string",
"description": "NL structured-extraction prompt."
"description": "Focus for Parallel excerpts; structured-extraction prompt for Firecrawl."
},
"schema": {
"description": "JSON schema for structured extraction."
Expand All @@ -3802,10 +3810,18 @@
},
{
"name": "web_search",
"description": "Search the web via Firecrawl /search (official API). Returns titles, URLs, descriptions; by default also scrapes markdown for each hit (scrapeMarkdown=true). Use site: / filetype: operators in query when helpful. Prefer this to inventing URLs. Then web_browser a specific URL for deep scrape/screenshot.",
"description": "Search the web. Returns titles, URLs and source excerpts through free anonymous Parallel Search MCP. A configured Firecrawl key preserves Firecrawl search and optional scraped markdown. Use provider to override; country/lang/tbs filters require Firecrawl. Use site: / filetype: operators in query when helpful. Prefer this to inventing URLs. Then web_browser a specific URL for deep scrape/screenshot.",
"input_schema": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"parallel",
"firecrawl"
],
"description": "Override the server default. Firecrawl requires a configured key."
},
"query": {
"type": "string",
"description": "Search query (supports operators like site:example.com)."
Expand All @@ -3828,7 +3844,7 @@
},
"scrapeMarkdown": {
"type": "boolean",
"description": "If true (default), include markdown for each result via scrapeOptions."
"description": "Firecrawl only: include scraped markdown for each result (default true). Parallel returns excerpts."
}
},
"required": [
Expand Down
2 changes: 2 additions & 0 deletions server/keystore-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export const KEY_NAMES = [
"E2B_API_KEY",
"E2B_TEMPLATE",
"FIRECRAWL_API_KEY",
"WEB_SEARCH_PROVIDER",
"UPLOAD_POST_API_KEY",
"UPLOAD_POST_PROFILE",
"UPLOAD_POST_BASE_URL",
Expand Down Expand Up @@ -193,6 +194,7 @@ export type KeyName = (typeof KEY_NAMES)[number];
// not credentials). Deliberately a separate explicit list rather than derived from
// KEY_NAMES: adding a key to the whitelist must never accidentally make it non-secret.
export const NON_SECRET_NAMES: ReadonlySet<string> = new Set([
"WEB_SEARCH_PROVIDER",
"AGENT_IMPORT_ROOTS",
"PROXY_URL",
"LLM_PROVIDER",
Expand Down
4 changes: 3 additions & 1 deletion server/keystore.verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ const MODEL_ROUTING_NAMES = [
'AGENT_IMPORT_ROOTS', // Agent local-path import whitelist (comma-separated absolute dirs)
'OPENCHATCUT_SKILLS_DIR', // User skill files directory (''=~/.openchatcut/skills)
'PROXY_URL', // Outbound network proxy (''=use HTTPS_PROXY/HTTP_PROXY env)
'WEB_SEARCH_PROVIDER', // Basic web research routing ('' | parallel | firecrawl | disabled)
] as const;
for (const name of MODEL_ROUTING_NAMES) {
assert.ok((KEY_NAMES as readonly string[]).includes(name), `${name} is whitelisted (settable via POST /api/keys)`);
Expand All @@ -191,8 +192,9 @@ assert.deepStrictEqual(
);

// seed one SECRET + one non-secret on top of the state above (seeds accumulate in-process)
seedKeystore({ ...isolatedSeed, LLM_API_KEY: 'sec-x', MINIMAX_TTS_MODEL: 'speech-2.8-hd' } as Record<string, string>);
seedKeystore({ ...isolatedSeed, LLM_API_KEY: 'sec-x', MINIMAX_TTS_MODEL: 'speech-2.8-hd', WEB_SEARCH_PROVIDER: 'disabled' } as Record<string, string>);
const st2 = keyStatus();
assert.equal(st2.models['WEB_SEARCH_PROVIDER'], 'disabled', 'web routing/disable choice is echoed as non-secret config');
assert.equal(st2.models['MINIMAX_TTS_MODEL'], 'speech-2.8-hd', 'non-secret model value echoed in models');
assert.equal(st2.models['KLING_VIDEO_MODEL'], '', 'unset non-secret name echoes empty string');
assert.ok(!('LLM_API_KEY' in st2.models), 'SECRET key has no field in models at all');
Expand Down
2 changes: 2 additions & 0 deletions server/plugins/firecrawl-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export const MAX_BODY = 2 * 1024 * 1024;

export interface FirecrawlPluginOptions {
apiKey: string;
/** Empty selects configured Firecrawl, otherwise anonymous Parallel. */
searchProvider?: string;
}

export function readJsonBody(req: IncomingMessage): Promise<unknown> {
Expand Down
36 changes: 32 additions & 4 deletions server/plugins/firecrawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// already import from this module are re-exported below.

import type { Plugin } from 'vite';
import { parallelWebRequest } from './parallel-search.ts';
import type { FirecrawlPluginOptions } from './firecrawl-shared.ts';
import { notConfigured, readJsonBody, sendJson } from './firecrawl-shared.ts';
import {
Expand All @@ -23,16 +24,42 @@ export function firecrawlPlugin(options: FirecrawlPluginOptions): Plugin {
configureServer(server) {
const log = (m: string) => server.config.logger.info(m);
const key = options.apiKey;
const providerFor = (body: Record<string, unknown>): string => {
if (options.searchProvider === 'disabled') throw new Error('Web search and page reading are disabled.');
const provider = body.provider ?? options.searchProvider;
if (provider && provider !== 'parallel' && provider !== 'firecrawl') {
throw new Error('WEB_SEARCH_PROVIDER must be parallel, firecrawl or disabled.');
}
return String(provider || (options.apiKey ? 'firecrawl' : 'parallel'));
};
const parallel = async (
res: import('node:http').ServerResponse,
operation: 'search' | 'fetch',
body: Record<string, unknown>,
) => {
const abort = new AbortController();
const cancel = () => { if (!res.writableEnded) abort.abort(); };
res.on('close', cancel);
const timeoutMs = operation === 'fetch' && typeof body.timeout === 'number' && Number.isFinite(body.timeout)
? Math.max(1, Math.min(60_000, Math.round(body.timeout))) : 45_000;
const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(timeoutMs)]);
try {
sendJson(res, 200, await parallelWebRequest(operation, body, signal));
} finally {
res.off('close', cancel);
}
};

server.middlewares.use('/api/web-browser', async (req, res) => {
if (req.method !== 'POST') {
sendJson(res, 405, { error: 'method not allowed — use POST' });
return;
}
try {
if (notConfigured(res, key)) return;
const body = (await readJsonBody(req)) as Record<string, unknown>;
await handleScrape(key, body, res, log);
if (providerFor(body) === 'parallel') return await parallel(res, 'fetch', body);
if (notConfigured(res, options.apiKey)) return;
await handleScrape(options.apiKey, body, res, log);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
server.config.logger.error(`[web-browser] ${message}`);
Expand All @@ -46,9 +73,10 @@ export function firecrawlPlugin(options: FirecrawlPluginOptions): Plugin {
return;
}
try {
if (notConfigured(res, key)) return;
const body = (await readJsonBody(req)) as Record<string, unknown>;
await handleSearch(key, body, res);
if (providerFor(body) === 'parallel') return await parallel(res, 'search', body);
if (notConfigured(res, options.apiKey)) return;
await handleSearch(options.apiKey, body, res);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
server.config.logger.error(`[firecrawl/search] ${message}`);
Expand Down
1 change: 1 addition & 0 deletions server/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ export function serverPlugins(options: { projectStoreHttp?: boolean } = {}): Plu
},
}),
firecrawlPlugin({
get searchProvider() { return getKey("WEB_SEARCH_PROVIDER"); },
get apiKey() {
return getKey("FIRECRAWL_API_KEY");
},
Expand Down
82 changes: 82 additions & 0 deletions server/plugins/parallel-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Anonymous Search MCP adapter. Provider selection stays in the web middleware;
// an explicit provider failure is returned to the caller without a fallback.
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { fetchWithProxy, isHttpUrl, truncate } from './firecrawl-shared.ts';

const ENDPOINT = 'https://search.parallel.ai/mcp';
const USER_AGENT = 'OpenChatCut (+https://github.com/0xsline/OpenChatCut)';

type Body = Record<string, unknown>;

export async function parallelWebRequest(
operation: 'search' | 'fetch',
body: Body,
signal: AbortSignal,
): Promise<Body> {
const query = typeof body.query === 'string' ? body.query.trim() : '';
const url = typeof body.url === 'string' ? body.url.trim() : '';
if (operation === 'search' && !query) throw new Error('query is required');
if (operation === 'fetch' && !isHttpUrl(url)) throw new Error('url must be a valid http(s) URI');
// Parallel returns relevant excerpts. Firecrawl keeps locale/time filters,
// screenshots, actions, structured extraction and complete-page formats.
const unsupported = operation === 'search'
? ['country', 'lang', 'tbs']
: ['country', 'waitFor', 'actions', 'execJs', 'schema', 'fullPage'];
if (unsupported.some((key) => body[key] != null && body[key] !== false && body[key] !== '')
|| (operation === 'fetch' && (body.onlyMainContent === false
|| (Array.isArray(body.formats) && body.formats.some((f) => f !== 'markdown'))))) {
throw new Error('These options require provider="firecrawl" and FIRECRAWL_API_KEY. Parallel supports search and page excerpts.');
}
const args: Body = operation === 'search'
? { objective: query, search_queries: [query] }
: { urls: [url], ...(query ? { objective: query.slice(0, 200) } : {}) };
if (typeof body.sessionId === 'string' && /^[a-f0-9-]{36}$/.test(body.sessionId)) {
args.session_id = body.sessionId;
}
const client = new Client({ name: 'openchatcut', version: '0.2.17' });
const transport = new StreamableHTTPClientTransport(new URL(ENDPOINT), {
fetch: (input, init) => {
const headers = new Headers(init?.headers);
headers.set('User-Agent', USER_AGENT);
return fetchWithProxy(input, { ...init, headers,
signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal });
},
});
try {
await client.connect(transport, { signal, timeout: 60_000 });
await client.listTools({}, { signal, timeout: 60_000 });
const result = CallToolResultSchema.parse(await client.callTool({
name: operation === 'search' ? 'web_search' : 'web_fetch', arguments: args,
}, CallToolResultSchema, { signal, timeout: 60_000 }));
const text = result.content
.filter((item) => item.type === 'text')
.map((item) => item.text).join('\n');
if (result.isError) throw new Error(text || 'Parallel Search MCP request failed');
const data = (result.structuredContent ?? JSON.parse(text)) as Body;
if (!Array.isArray(data.results)) throw new Error('Invalid Parallel Search MCP results');
const limit = typeof body.limit === 'number' && Number.isFinite(body.limit)
? Math.max(1, Math.min(20, Math.round(body.limit))) : 5;
const results = data.results.slice(0, operation === 'search' ? limit : 1).map((value) => {
if (!value || typeof value !== 'object') throw new Error('Invalid Parallel Search MCP result');
const item = value as Body;
if (typeof item.url !== 'string' || !isHttpUrl(item.url) || !Array.isArray(item.excerpts)
|| item.excerpts.some((excerpt) => typeof excerpt !== 'string')) {
throw new Error('Invalid Parallel Search MCP result');
}
const excerpts = (item.excerpts as string[]).map((excerpt) => truncate(excerpt, 12_000));
return { url: item.url, title: item.title, description: excerpts.join('\n\n'), excerpts };
});
if (operation === 'search') {
return { configured: true, ok: true, provider: 'parallel', query, count: results.length,
results, warnings: data.warnings };
}
if (!results.length) throw new Error(JSON.stringify(data.errors ?? 'Parallel could not read the page'));
return { configured: true, ok: true, provider: 'parallel', url: results[0]!.url,
metadata: { title: results[0]!.title }, markdown: results[0]!.description,
excerpts: results[0]!.excerpts, warnings: data.warnings };
} finally {
await client.close();
}
}
Loading