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
32 changes: 23 additions & 9 deletions media/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
YSMsimon marked this conversation as resolved.
Expand Down Expand Up @@ -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;
Comment thread
YSMsimon marked this conversation as resolved.
Expand Down Expand Up @@ -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);
Comment thread
YSMsimon marked this conversation as resolved.
Expand All @@ -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
Comment thread
YSMsimon marked this conversation as resolved.
Expand Down
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
YSMsimon marked this conversation as resolved.
Expand Down
19 changes: 12 additions & 7 deletions src/chat/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在此处新增了 webSearchProvider 的配置项,但未对其进行有效性验证。建议在使用 wsProvider 之前,检查其是否符合预期格式,以防止潜在的配置错误或注入攻击。此外,考虑到安全性,建议对所有从配置中获取的密钥和 URL 进行适当的清理和验证。

Expand Down Expand Up @@ -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,
Comment thread
YSMsimon marked this conversation as resolved.
// persist it so deepseekAgent.defaultModel is always consistent.
if (msg.model) {
Comment thread
YSMsimon marked this conversation as resolved.
Expand Down
176 changes: 128 additions & 48 deletions src/tools/web-search.js
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────────────────────────────────

Comment thread
YSMsimon marked this conversation as resolved.
Comment thread
YSMsimon marked this conversation as resolved.
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(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&amp;/g, '&')
.replace(/<[^>]+>/g, ' ').replace(/\s{2,}/g, ' ').trim();
}

Comment thread
YSMsimon marked this conversation as resolved.
function _parseBingRss(xml) {
const results = [];
const items = xml.split('<item>').slice(1);
for (const item of items) {
const titleM = item.match(/<title>([\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)}`; }
}

Comment thread
YSMsimon marked this conversation as resolved.
Expand Down
29 changes: 19 additions & 10 deletions src/webview/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Comment thread
YSMsimon marked this conversation as resolved.
Expand Down
Loading