diff --git a/media/chat.js b/media/chat.js index ac537b6..769991c 100644 --- a/media/chat.js +++ b/media/chat.js @@ -2273,6 +2273,8 @@ var stgDsLink = document.getElementById('s-ds-link'); var stgBaseUrl = document.getElementById('s-base-url'); var stgBaseReset = document.getElementById('s-base-url-reset'); + var stgWsProvider = document.getElementById('s-ws-provider'); + var stgTvSection = document.getElementById('s-tv-section'); var stgTvKey = document.getElementById('s-tv-key'); var stgTvEye = document.getElementById('s-tv-key-eye'); var stgTvTest = document.getElementById('s-tv-test'); @@ -2305,6 +2307,12 @@ }, 5000); } } + function _stgUpdateWsProvider(){ + var v = stgWsProvider ? stgWsProvider.value : 'tavily'; + if (stgTvSection) stgTvSection.style.display = v === 'tavily' ? '' : 'none'; + } + if (stgWsProvider) stgWsProvider.addEventListener('change', _stgUpdateWsProvider); + function openSettingsModal(){ if (_stgOpen) return; _stgOpen = true; @@ -2406,17 +2414,19 @@ vscode.postMessage({ type: 'testApiKey', which: 'tv', key: key || null }); }); stgSaveBtn && stgSaveBtn.addEventListener('click', function(){ - var dsKey = stgDsKey ? stgDsKey.value.trim() : null; - var tvKey = stgTvKey ? stgTvKey.value.trim() : null; - var baseUrl = stgBaseUrl ? stgBaseUrl.value.trim() : null; - var provider = stgProvider ? stgProvider.value : 'deepseek'; + var dsKey = stgDsKey ? stgDsKey.value.trim() : null; + var tvKey = stgTvKey ? stgTvKey.value.trim() : null; + var baseUrl = stgBaseUrl ? stgBaseUrl.value.trim() : null; + var provider = stgProvider ? stgProvider.value : 'deepseek'; + var wsProvider = stgWsProvider ? stgWsProvider.value : 'tavily'; vscode.postMessage({ type: 'saveApiSettings', - dsKey: dsKey || null, - tvKey: tvKey || null, - baseUrl: baseUrl !== null ? baseUrl : _stgOrigBaseUrl, - provider: provider, - model: getSelectedModel(), + dsKey: dsKey || null, + tvKey: tvKey || null, + baseUrl: baseUrl !== null ? baseUrl : _stgOrigBaseUrl, + provider: provider, + webSearchProvider: wsProvider, + model: getSelectedModel(), }); switchToProvider(provider); closeSettingsModal(true); @@ -2439,6 +2449,10 @@ if (stgDsKey) { stgDsKey.value = ''; stgDsKey.placeholder = _stgDsKeySet ? (m.dsKeyHint || '(configured)') : 'sk-...'; } if (stgTvKey) { stgTvKey.value = ''; stgTvKey.placeholder = _stgTvKeySet ? (m.tvKeyHint || '(configured)') : 'tvly-...'; } if (stgBaseUrl) stgBaseUrl.value = _stgOrigBaseUrl; + if (stgWsProvider) { + stgWsProvider.value = m.webSearchProvider || 'tavily'; + _stgUpdateWsProvider(); + } if (stgProvider) { stgProvider.value = _stgOrigProvider; // Update the key link for the current provider diff --git a/package.json b/package.json index 319b66a..20def06 100644 --- a/package.json +++ b/package.json @@ -273,6 +273,16 @@ "minimum": 8000, "description": "Approximate token budget before older tool results are auto-compacted to fit the model context window." }, + "deepseekAgent.webSearchProvider": { + "type": "string", + "default": "tavily", + "enum": ["tavily", "bing"], + "enumDescriptions": [ + "Tavily — best quality, requires API key (1000 free searches/month)", + "Bing — no API key required, HTML scrape" + ], + "description": "Web search backend. Bing and DuckDuckGo require no API key and work out of the box." + }, "deepseekAgent.postEditDiagnostics": { "type": "boolean", "default": true, diff --git a/src/chat/provider.js b/src/chat/provider.js index 1647e87..75401c4 100644 --- a/src/chat/provider.js +++ b/src/chat/provider.js @@ -276,15 +276,17 @@ class ChatViewProvider { const tvKey = await this._context.secrets.get('deepseekAgent.tavilyKey') || ''; const baseUrl = cfg.get('apiBaseUrl') || ''; const provider = cfg.get('provider') || 'deepseek'; + const wsProvider = cfg.get('webSearchProvider') || 'tavily'; const maskKey = (k) => k ? (k.slice(0, 6) + '...' + k.slice(-4)) : ''; this._post({ - type: 'settingsLoaded', - dsKeySet: !!dsKey, - dsKeyHint: maskKey(dsKey), - tvKeySet: !!tvKey, - tvKeyHint: maskKey(tvKey), - baseUrl: baseUrl, - provider: provider, + type: 'settingsLoaded', + dsKeySet: !!dsKey, + dsKeyHint: maskKey(dsKey), + tvKeySet: !!tvKey, + tvKeyHint: maskKey(tvKey), + baseUrl: baseUrl, + provider: provider, + webSearchProvider: wsProvider, }); break; } @@ -409,6 +411,9 @@ class ChatViewProvider { if (msg.provider) { await cfg.update('provider', msg.provider, vscode.ConfigurationTarget.Global); } + if (msg.webSearchProvider && ['tavily', 'bing'].includes(msg.webSearchProvider)) { + await cfg.update('webSearchProvider', msg.webSearchProvider, vscode.ConfigurationTarget.Global); + } // If the UI sent the currently selected model alongside the provider, // persist it so deepseekAgent.defaultModel is always consistent. if (msg.model) { diff --git a/src/tools/web-search.js b/src/tools/web-search.js index d69ed7e..911651d 100644 --- a/src/tools/web-search.js +++ b/src/tools/web-search.js @@ -1,82 +1,162 @@ -// web_search: Tavily-backed web search. -// API key stored in VS Code SecretStorage under 'deepseekAgent.tavilyKey'. +// web_search: multi-backend web search. +// Backends: +// - Tavily (requires API key, best quality) +// - Bing (no API key required, HTML scrape via DuckDuckGo-lite endpoint) +// - DuckDuckGo (no API key required, HTML scrape) +// +// Active backend is determined by the 'deepseekAgent.webSearchProvider' setting. +// Falls back to Tavily when configured. 'use strict'; -const https = require('https'); +const https = require('https'); +const http = require('http'); const { truncate } = require('./utils'); -function _tavilyRequest(payload, timeoutMs = 20000, abortSignal = null) { +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function _request(opts, body, timeoutMs = 20000, abortSignal = null) { return new Promise((resolve, reject) => { if (abortSignal && abortSignal.aborted) return reject(new Error('aborted')); - const body = JSON.stringify(payload); - const req = https.request({ - method: 'POST', - hostname: 'api.tavily.com', - path: '/search', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': Buffer.byteLength(body), - }, - timeout: timeoutMs, - }, (res) => { + const mod = opts.protocol === 'http:' ? http : https; + const req = mod.request(opts, (res) => { let chunks = ''; res.setEncoding('utf8'); res.on('data', (c) => { chunks += c; }); - res.on('end', () => { - if (res.statusCode < 200 || res.statusCode >= 300) - return reject(new Error(`Tavily HTTP ${res.statusCode}: ${chunks.slice(0, 500)}`)); - try { resolve(JSON.parse(chunks)); } - catch (e) { reject(new Error(`Tavily JSON parse failed: ${e.message}`)); } - }); + res.on('end', () => resolve({ status: res.statusCode, body: chunks })); }); req.on('error', reject); - req.on('timeout', () => { req.destroy(new Error('Tavily request timeout')); }); + req.on('timeout', () => { req.destroy(new Error('Request timeout')); }); if (abortSignal) { const onAbort = () => { try { req.destroy(new Error('aborted')); } catch {} reject(new Error('aborted')); }; abortSignal.addEventListener('abort', onAbort, { once: true }); req.once('close', () => { try { abortSignal.removeEventListener('abort', onAbort); } catch {} }); } - req.write(body); + req.setTimeout(timeoutMs); + if (body) req.write(body); req.end(); }); } +// ─── Tavily backend ──────────────────────────────────────────────────────────── + +async function _tavilySearch(query, { apiKey, max = 5, depth = 'basic', abortSignal } = {}) { + const body = JSON.stringify({ + api_key: apiKey, query, max_results: max, + search_depth: depth, include_answer: true, + include_raw_content: false, include_images: false, + }); + const res = await _request({ + method: 'POST', hostname: 'api.tavily.com', path: '/search', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + }, body, 20000, abortSignal); + + if (res.status < 200 || res.status >= 300) + throw new Error(`Tavily HTTP ${res.status}: ${res.body.slice(0, 200)}`); + + const data = JSON.parse(res.body); + const lines = [`Query: ${query}`]; + if (data.answer) lines.push('', '## Synthesized answer', data.answer); + const results = Array.isArray(data.results) ? data.results : []; + if (!results.length) { + lines.push('', '(No results.)'); + } else { + lines.push('', `## Top ${results.length} result(s)`); + results.forEach((r, i) => { + lines.push('', `### ${i + 1}. ${(r.title || '').replace(/\s+/g, ' ').trim()}`); + if (r.url) lines.push(r.url); + if (r.content) lines.push(r.content.replace(/\s+/g, ' ').trim()); + }); + } + return truncate(lines.join('\n')); +} + +// ─── Bing RSS backend (no API key) ──────────────────────────────────────────── +// Uses Bing's ?format=rss endpoint which returns stable XML — no bot detection, +// no HTML scraping fragility. + +function _decodeXmlEntities(s) { + return String(s || '') + .replace(/</g, '<').replace(/>/g, '>') + .replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replace(/&/g, '&') + .replace(/<[^>]+>/g, ' ').replace(/\s{2,}/g, ' ').trim(); +} + +function _parseBingRss(xml) { + const results = []; + const items = xml.split('').slice(1); + for (const item of items) { + const titleM = item.match(/([\s\S]*?)<\/title>/i); + const linkM = item.match(/<link>([\s\S]*?)<\/link>/i) + || item.match(/<link\s+[^>]*href="([^"]+)"/i); + const descM = item.match(/<description>([\s\S]*?)<\/description>/i); + if (titleM && linkM) { + results.push({ + title: _decodeXmlEntities(titleM[1]).slice(0, 120), + url: _decodeXmlEntities(linkM[1]).trim(), + snippet: descM ? _decodeXmlEntities(descM[1]).slice(0, 300) : '', + }); + } + } + return results; +} + +async function _bingSearch(query, { max = 5, abortSignal } = {}) { + const q = encodeURIComponent(query); + const res = await _request({ + method: 'GET', + hostname: 'www.bing.com', + path: `/search?q=${q}&format=rss&count=${max}`, + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; DeepCopilot/1.0)', + 'Accept': 'application/rss+xml, text/xml, */*', + 'Accept-Encoding': 'identity', + }, + }, null, 15000, abortSignal); + + if (res.status < 200 || res.status >= 300) + throw new Error(`Bing HTTP ${res.status}`); + + const results = _parseBingRss(res.body); + if (!results.length) return `Query: ${query}\n\n(No results from Bing.)`; + + const lines = [`Query: ${query}`, '', `## Top ${results.length} result(s)`]; + results.slice(0, max).forEach((r, i) => { + lines.push('', `### ${i + 1}. ${r.title}`); + if (r.url) lines.push(r.url); + if (r.snippet) lines.push(r.snippet); + }); + return truncate(lines.join('\n')); +} + +// ─── Main dispatch ───────────────────────────────────────────────────────────── + async function toolWebSearch(args, ctx = {}) { try { const query = String(args.query || '').trim(); if (!query) return 'Error: query is empty.'; + const vscode = require('vscode'); + const cfg = vscode.workspace.getConfiguration('deepseekAgent'); + const provider = cfg.get('webSearchProvider') || 'tavily'; + const max = Math.max(1, Math.min(10, Number.isFinite(args.max_results) ? args.max_results : 5)); + const abortSignal = ctx && ctx.abortSignal; + + if (provider === 'bing') { + return await _bingSearch(query, { max, abortSignal }); + } + + // Default: Tavily (requires key) const secrets = ctx && ctx.secrets; if (!secrets) return 'Error: SecretStorage unavailable (internal).'; const apiKey = await secrets.get('deepseekAgent.tavilyKey'); if (!apiKey) { - return 'Error: Tavily API key not configured. Run command "Deep Copilot: Set Tavily API Key" (or visit https://app.tavily.com to get a free key), then retry.'; + return 'Error: Tavily API key not configured. Run command "Deep Copilot: Set Tavily API Key" or switch to Bing/DuckDuckGo in settings (no key required).'; } + const depth = args.search_depth === 'advanced' ? 'advanced' : 'basic'; + return await _tavilySearch(query, { apiKey, max, depth, abortSignal }); - const max = Math.max(1, Math.min(10, Number.isFinite(args.max_results) ? args.max_results : 5)); - const depth = args.search_depth === 'advanced' ? 'advanced' : 'basic'; - const includeAnswer = args.include_answer !== false; - - const data = await _tavilyRequest({ - api_key: apiKey, query, max_results: max, - search_depth: depth, include_answer: includeAnswer, - include_raw_content: false, include_images: false, - }, 20000, ctx && ctx.abortSignal); - - const lines = [`Query: ${query}`]; - if (includeAnswer && data.answer) { lines.push('', '## Synthesized answer', data.answer); } - const results = Array.isArray(data.results) ? data.results : []; - if (!results.length) { - lines.push('', '(No results.)'); - } else { - lines.push('', `## Top ${results.length} result(s)`); - results.forEach((r, i) => { - lines.push('', `### ${i + 1}. ${(r.title || '(no title)').replace(/\s+/g, ' ').trim()}`); - if (r.url) lines.push(r.url); - if (r.content) lines.push((r.content).replace(/\s+/g, ' ').trim()); - }); - } - return truncate(lines.join('\n')); } catch (e) { return `Error: ${e.message || String(e)}`; } } diff --git a/src/webview/html.js b/src/webview/html.js index 4c10553..1ab21ab 100644 --- a/src/webview/html.js +++ b/src/webview/html.js @@ -193,18 +193,27 @@ function buildWebviewHtml(webview, extensionUri) { </div> <div class="settings-divider"></div> <div class="settings-section"> - <div class="settings-section-label">Web Search <span class="settings-section-badge">Tavily</span></div> + <div class="settings-section-label">Web Search</div> <div class="settings-field"> - <label class="settings-label" for="s-tv-key">API Key <span class="settings-optional">(optional)</span></label> - <div class="settings-input-row"> - <input type="password" id="s-tv-key" class="settings-input" placeholder="tvly-..." autocomplete="off" spellcheck="false"/> - <button class="settings-eye-btn" id="s-tv-key-eye" title="Show / hide" aria-label="Toggle key visibility">👁</button> - </div> - <div class="settings-test-row"> - <button class="settings-test-btn" id="s-tv-test">▶ Test connection</button> - <span class="settings-test-result" id="s-tv-result"></span> + <label class="settings-label" for="s-ws-provider">Provider</label> + <select id="s-ws-provider" class="settings-input settings-select"> + <option value="tavily">Tavily (needs API key, best quality)</option> + <option value="bing">Bing (no API key required)</option> + </select> + </div> + <div id="s-tv-section"> + <div class="settings-field"> + <label class="settings-label" for="s-tv-key">Tavily API Key <span class="settings-optional">(optional)</span></label> + <div class="settings-input-row"> + <input type="password" id="s-tv-key" class="settings-input" placeholder="tvly-..." autocomplete="off" spellcheck="false"/> + <button class="settings-eye-btn" id="s-tv-key-eye" title="Show / hide" aria-label="Toggle key visibility">👁</button> + </div> + <div class="settings-test-row"> + <button class="settings-test-btn" id="s-tv-test">▶ Test connection</button> + <span class="settings-test-result" id="s-tv-result"></span> + </div> + <a class="settings-link" id="s-tv-link" href="#">↗ app.tavily.com · 1000 free searches/month</a> </div> - <a class="settings-link" id="s-tv-link" href="#">↗ app.tavily.com · 1000 free searches/month</a> </div> </div> </div>