mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(search): add Querit provider (#2753)
Co-authored-by: wangxinwei03 <wangxinwei03@MacBook-Pro.local>
This commit is contained in:
co-authored by
wangxinwei03
parent
3278f2cb52
commit
5a5cd43ea9
@@ -129,6 +129,14 @@ OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS=10
|
||||
# [optional] Default max results per query (1-20, default 10)
|
||||
# OPENHUMAN_SELTZ_MAX_RESULTS=10
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Querit search (direct API — https://www.querit.ai)
|
||||
# ---------------------------------------------------------------------------
|
||||
# [optional] API key from https://www.querit.ai. Select "Querit" in
|
||||
# Settings > Search engine, or set OPENHUMAN_SEARCH_ENGINE=querit.
|
||||
# QUERIT_API_KEY=
|
||||
# OPENHUMAN_QUERIT_API_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SearXNG search (self-hosted — https://docs.searxng.org)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* - "Block all" → persists `allowed_domains: []` + `allow_all: false`,
|
||||
* - "Custom" → reveals the host editor and saving persists the list.
|
||||
*/
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../test/test-utils';
|
||||
@@ -42,6 +42,7 @@ function settings(overrides: Record<string, unknown> = {}) {
|
||||
timeout_secs: 15,
|
||||
parallel_configured: false,
|
||||
brave_configured: false,
|
||||
querit_configured: false,
|
||||
allowed_domains: ['reuters.com'],
|
||||
allow_all: false,
|
||||
...overrides,
|
||||
@@ -54,6 +55,7 @@ const CUSTOM = 'settings.search.accessCustom';
|
||||
const BLOCK_ALL = 'settings.search.accessBlockAll';
|
||||
|
||||
const radio = (name: string) => screen.getByRole('radio', { name });
|
||||
const keyEditor = (label: string) => within(screen.getByRole('group', { name: label }));
|
||||
|
||||
describe('SearchPanel — unified web-access modes', () => {
|
||||
beforeEach(() => {
|
||||
@@ -170,4 +172,66 @@ describe('SearchPanel — unified web-access modes', () => {
|
||||
const reopened = (await screen.findByPlaceholderText(PLACEHOLDER)) as HTMLTextAreaElement;
|
||||
expect(reopened.value).toBe('example.com');
|
||||
});
|
||||
|
||||
test('saving Parallel and Brave API keys sends provider-specific patches', async () => {
|
||||
renderWithProviders(<SearchPanel embedded />);
|
||||
await screen.findByPlaceholderText('settings.search.placeholderParallel');
|
||||
|
||||
const parallel = keyEditor('settings.search.parallelKeyLabel');
|
||||
const parallelInput = parallel.getByPlaceholderText(
|
||||
'settings.search.placeholderParallel'
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(parallelInput, { target: { value: 'parallel-test-key' } });
|
||||
fireEvent.click(parallel.getByText('settings.search.save'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hoisted.updateSearchSettings).toHaveBeenCalledWith({
|
||||
parallel_api_key: 'parallel-test-key',
|
||||
})
|
||||
);
|
||||
expect(parallelInput.value).toBe('');
|
||||
|
||||
const brave = keyEditor('settings.search.braveKeyLabel');
|
||||
const braveInput = brave.getByPlaceholderText(
|
||||
'settings.search.placeholderBrave'
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(braveInput, { target: { value: 'brave-test-key' } });
|
||||
fireEvent.click(brave.getByText('settings.search.save'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hoisted.updateSearchSettings).toHaveBeenCalledWith({ brave_api_key: 'brave-test-key' })
|
||||
);
|
||||
expect(braveInput.value).toBe('');
|
||||
});
|
||||
|
||||
test('Querit key editor can reveal, save, and clear the stored API key', async () => {
|
||||
hoisted.getSearchSettings.mockResolvedValue({ result: settings({ querit_configured: true }) });
|
||||
renderWithProviders(<SearchPanel embedded />);
|
||||
await screen.findByPlaceholderText('settings.search.placeholderStored');
|
||||
|
||||
const querit = keyEditor('settings.search.queritKeyLabel');
|
||||
const input = querit.getByPlaceholderText(
|
||||
'settings.search.placeholderStored'
|
||||
) as HTMLInputElement;
|
||||
expect(input.type).toBe('password');
|
||||
|
||||
fireEvent.click(querit.getByText('settings.search.show'));
|
||||
expect(input.type).toBe('text');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'querit-test-key' } });
|
||||
fireEvent.click(querit.getByText('settings.search.save'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hoisted.updateSearchSettings).toHaveBeenCalledWith({
|
||||
querit_api_key: 'querit-test-key',
|
||||
})
|
||||
);
|
||||
expect(input.value).toBe('');
|
||||
|
||||
fireEvent.click(querit.getByText('settings.search.clear'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hoisted.updateSearchSettings).toHaveBeenCalledWith({ querit_api_key: '' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
@@ -62,8 +62,10 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
|
||||
const [status, setStatus] = useState<Status>({ kind: 'loading' });
|
||||
const [parallelKey, setParallelKey] = useState<string>('');
|
||||
const [braveKey, setBraveKey] = useState<string>('');
|
||||
const [queritKey, setQueritKey] = useState<string>('');
|
||||
const [showParallel, setShowParallel] = useState(false);
|
||||
const [showBrave, setShowBrave] = useState(false);
|
||||
const [showQuerit, setShowQuerit] = useState(false);
|
||||
// Editor text for the allowed-websites host list (one host per line). The
|
||||
// "*" wildcard is represented by the access mode, not shown here.
|
||||
const [allowedText, setAllowedText] = useState<string>('');
|
||||
@@ -93,6 +95,12 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
|
||||
description: t('settings.search.engineBraveDesc'),
|
||||
requiresKey: true,
|
||||
},
|
||||
{
|
||||
id: 'querit',
|
||||
label: t('settings.search.engineQueritLabel'),
|
||||
description: t('settings.search.engineQueritDesc'),
|
||||
requiresKey: true,
|
||||
},
|
||||
];
|
||||
const visibleEngines = isLocalSession
|
||||
? ENGINES.filter(engine => engine.id !== 'managed')
|
||||
@@ -142,17 +150,22 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const persistKey = async (engine: 'parallel' | 'brave', rawKey: string) => {
|
||||
const persistKey = async (engine: 'parallel' | 'brave' | 'querit', rawKey: string) => {
|
||||
if (!settings) return;
|
||||
setStatus({ kind: 'saving' });
|
||||
try {
|
||||
await openhumanUpdateSearchSettings(
|
||||
engine === 'parallel' ? { parallel_api_key: rawKey } : { brave_api_key: rawKey }
|
||||
);
|
||||
const update =
|
||||
engine === 'parallel'
|
||||
? { parallel_api_key: rawKey }
|
||||
: engine === 'brave'
|
||||
? { brave_api_key: rawKey }
|
||||
: { querit_api_key: rawKey };
|
||||
await openhumanUpdateSearchSettings(update);
|
||||
const refreshed = await openhumanGetSearchSettings();
|
||||
setSettings(refreshed.result);
|
||||
if (engine === 'parallel') setParallelKey('');
|
||||
else setBraveKey('');
|
||||
else if (engine === 'brave') setBraveKey('');
|
||||
else setQueritKey('');
|
||||
setStatus({ kind: 'saved' });
|
||||
} catch (err) {
|
||||
setStatus({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
|
||||
@@ -196,6 +209,7 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
|
||||
if (engine === 'managed') return true;
|
||||
if (engine === 'parallel') return settings.parallel_configured;
|
||||
if (engine === 'brave') return settings.brave_configured;
|
||||
if (engine === 'querit') return settings.querit_configured;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -334,6 +348,23 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
|
||||
docUrl="https://brave.com/search/api/"
|
||||
t={t}
|
||||
/>
|
||||
<KeyEditor
|
||||
label={t('settings.search.queritKeyLabel')}
|
||||
placeholder={
|
||||
settings.querit_configured
|
||||
? t('settings.search.placeholderStored')
|
||||
: t('settings.search.placeholderQuerit')
|
||||
}
|
||||
show={showQuerit}
|
||||
onToggleShow={() => setShowQuerit(s => !s)}
|
||||
value={queritKey}
|
||||
onChange={setQueritKey}
|
||||
onSave={() => void persistKey('querit', queritKey)}
|
||||
onClear={() => void persistKey('querit', '')}
|
||||
configured={settings.querit_configured}
|
||||
docUrl="https://www.querit.ai/en/docs/reference/post"
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Allowed websites — unified host allowlist shared by web_fetch /
|
||||
@@ -450,49 +481,62 @@ const KeyEditor = ({
|
||||
configured,
|
||||
docUrl,
|
||||
t,
|
||||
}: KeyEditorProps) => (
|
||||
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-semibold text-stone-700 dark:text-neutral-200">{label}</label>
|
||||
<a
|
||||
href={docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-primary-500 hover:underline">
|
||||
{t('settings.search.getApiKey')} ↗
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 min-w-0 px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleShow}
|
||||
className="px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 text-xs text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800">
|
||||
{show ? t('settings.search.hide') : t('settings.search.show')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={value.trim().length === 0}
|
||||
className="px-3 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium disabled:opacity-50">
|
||||
{t('settings.search.save')}
|
||||
</button>
|
||||
{configured && (
|
||||
}: KeyEditorProps) => {
|
||||
const inputId = useId();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={inputId}
|
||||
className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label
|
||||
id={inputId}
|
||||
htmlFor={`${inputId}-input`}
|
||||
className="text-xs font-semibold text-stone-700 dark:text-neutral-200">
|
||||
{label}
|
||||
</label>
|
||||
<a
|
||||
href={docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[10px] text-primary-500 hover:underline">
|
||||
{t('settings.search.getApiKey')} ↗
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={`${inputId}-input`}
|
||||
type={show ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 min-w-0 px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 text-xs font-mono text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="px-2 py-1.5 rounded-md border border-coral-200 dark:border-coral-500/30 text-xs text-coral-600 dark:text-coral-300 hover:bg-coral-50 dark:hover:bg-coral-500/10">
|
||||
{t('settings.search.clear')}
|
||||
onClick={onToggleShow}
|
||||
className="px-2 py-1.5 rounded-md border border-stone-200 dark:border-neutral-800 text-xs text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800">
|
||||
{show ? t('settings.search.hide') : t('settings.search.show')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={value.trim().length === 0}
|
||||
className="px-3 py-1.5 rounded-md bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium disabled:opacity-50">
|
||||
{t('settings.search.save')}
|
||||
</button>
|
||||
{configured && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="px-2 py-1.5 rounded-md border border-coral-200 dark:border-coral-500/30 text-xs text-coral-600 dark:text-coral-300 hover:bg-coral-50 dark:hover:bg-coral-500/10">
|
||||
{t('settings.search.clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPanel;
|
||||
|
||||
@@ -584,6 +584,9 @@ const ar1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave بحث',
|
||||
'settings.search.engineBraveDesc': 'مباشر Brave بحث API: أدوات الويب والأخبار والصور والفيديو.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'تم تكوينه',
|
||||
'settings.search.statusNeedsKey': 'يحتاج إلى مفتاح API',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -598,9 +601,11 @@ const ar1: TranslationMap = {
|
||||
'settings.search.statusError': 'فشل',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API مفتاح',
|
||||
'settings.search.braveKeyLabel': 'Brave بحث API مفتاح',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (مخزن)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'التضمينات',
|
||||
'settings.embeddings.description':
|
||||
'اختر مزود التضمينات الذي يحول الذاكرة إلى متجهات للبحث الدلالي. تغيير المزود أو النموذج أو الأبعاد يبطل المتجهات المخزنة ويتطلب إعادة تعيين كاملة للذاكرة.',
|
||||
@@ -681,7 +686,7 @@ const ar1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'افتح إعدادات الحساب',
|
||||
'channels.localManagedUnavailable': 'القنوات المُدارة غير متاحة للمستخدمين المحليين.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'مسار التنقل',
|
||||
|
||||
@@ -592,6 +592,9 @@ const bn1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave অনুসন্ধান',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'কনফিগার করা',
|
||||
'settings.search.statusNeedsKey': 'API কী প্রয়োজন',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -606,9 +609,11 @@ const bn1: TranslationMap = {
|
||||
'settings.search.statusError': 'ব্যর্থ হয়েছে',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API কী',
|
||||
'settings.search.braveKeyLabel': 'Brave অনুসন্ধান API কী',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '(•••stor)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'এমবেডিংস',
|
||||
'settings.embeddings.description':
|
||||
'কোন এমবেডিং প্রদানকারী মেমরিকে সিমান্টিক সার্চের জন্য ভেক্টরে রূপান্তর করে তা চয়ন করুন। প্রদানকারী, মডেল বা মাত্রা পরিবর্তন করলে সংরক্ষিত ভেক্টর অবৈধ হয়ে যায় এবং সম্পূর্ণ মেমরি রিসেট প্রয়োজন।',
|
||||
@@ -689,7 +694,7 @@ const bn1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'অ্যাকাউন্ট সেটিংস খুলুন',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'ব্রেডক্রাম্ব নেভিগেশন',
|
||||
|
||||
@@ -602,6 +602,9 @@ const de1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Suche',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'Konfiguriert',
|
||||
'settings.search.statusNeedsKey': 'Benötigt API-Schlüssel',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -616,9 +619,11 @@ const de1: TranslationMap = {
|
||||
'settings.search.statusError': 'Schlüssel',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API fehlgeschlagen',
|
||||
'settings.search.braveKeyLabel': 'Brave Suche API Schlüssel',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (gespeichert)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'Einbettungen',
|
||||
'settings.embeddings.description':
|
||||
'Wählen Sie den Embedding-Anbieter, der Erinnerungen in Vektoren für die semantische Suche umwandelt. Das Ändern des Anbieters, Modells oder der Dimensionen macht gespeicherte Vektoren ungültig und erfordert einen vollständigen Speicher-Reset.',
|
||||
@@ -702,7 +707,7 @@ const de1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'Kontoeinstellungen öffnen',
|
||||
'channels.localManagedUnavailable': 'Verwaltete Kanäle sind für lokale Benutzer nicht verfügbar.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Navigationspfad',
|
||||
|
||||
@@ -1135,18 +1135,21 @@ const en1: TranslationMap = {
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel, Brave, and Querit run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -1161,9 +1164,11 @@ const en1: TranslationMap = {
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.search.allowedSitesLabel': 'Allowed websites',
|
||||
'settings.search.allowedSitesHint':
|
||||
'Hosts the assistant may open and read — via web fetch and the browser tool — one per line, e.g. reuters.com. A host also covers its subdomains. Web search itself is not restricted by this list.',
|
||||
|
||||
@@ -604,6 +604,9 @@ const es1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Buscar',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'Configurado',
|
||||
'settings.search.statusNeedsKey': 'Necesita la clave API',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -618,9 +621,11 @@ const es1: TranslationMap = {
|
||||
'settings.search.statusError': 'Fallido',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API clave',
|
||||
'settings.search.braveKeyLabel': 'Brave Buscar clave API',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (almacenado)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'Incrustaciones',
|
||||
'settings.embeddings.description':
|
||||
'Elige qué proveedor de embeddings convierte la memoria en vectores para la búsqueda semántica. Cambiar el proveedor, modelo o dimensiones invalida los vectores almacenados y requiere un reinicio completo de la memoria.',
|
||||
@@ -701,7 +706,7 @@ const es1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'Abrir configuración de cuenta',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Ruta de navegación',
|
||||
|
||||
@@ -606,6 +606,9 @@ const fr1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Recherche',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'Configuré',
|
||||
'settings.search.statusNeedsKey': 'Nécessite la clé API',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -620,9 +623,11 @@ const fr1: TranslationMap = {
|
||||
'settings.search.statusError': 'Échec',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API clé',
|
||||
'settings.search.braveKeyLabel': 'Brave Recherche API clé',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stocké)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'Encastrements',
|
||||
'settings.embeddings.description':
|
||||
"Choisissez le fournisseur d'embeddings qui convertit la mémoire en vecteurs pour la recherche sémantique. Changer le fournisseur, le modèle ou les dimensions invalide les vecteurs stockés et nécessite une réinitialisation complète de la mémoire.",
|
||||
@@ -704,7 +709,7 @@ const fr1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'Ouvrir les paramètres du compte',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Fil d’Ariane',
|
||||
|
||||
@@ -590,6 +590,9 @@ const hi1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave खोजें',
|
||||
'settings.search.engineBraveDesc': 'प्रत्यक्ष Brave खोजें API: वेब, समाचार, छवि और वीडियो उपकरण।',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'विन्यस्त',
|
||||
'settings.search.statusNeedsKey': 'API कुंजी की आवश्यकता है',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -604,9 +607,11 @@ const hi1: TranslationMap = {
|
||||
'settings.search.statusError': 'असफल',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API कुंजी',
|
||||
'settings.search.braveKeyLabel': 'Brave API कुंजी खोजें',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (संग्रहीत)',
|
||||
'settings.search.placeholderParallel': 'पीके_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'एम्बेडिंग्स',
|
||||
'settings.embeddings.description':
|
||||
'चुनें कि कौन सा एम्बेडिंग प्रदाता मेमोरी को सिमेंटिक सर्च के लिए वेक्टर में बदलता है। प्रदाता, मॉडल या आयाम बदलने से संग्रहीत वेक्टर अमान्य हो जाते हैं और पूर्ण मेमरी रीसेट की आवश्यकता होती है।',
|
||||
@@ -688,7 +693,7 @@ const hi1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'खाता सेटिंग खोलें',
|
||||
'channels.localManagedUnavailable': 'प्रबंधित चैनल स्थानीय उपयोगकर्ताओं के लिए उपलब्ध नहीं हैं.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'ब्रेडक्रंब नेविगेशन',
|
||||
|
||||
@@ -595,6 +595,9 @@ const id1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Penelusuran',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'Dikonfigurasi',
|
||||
'settings.search.statusNeedsKey': 'Memerlukan kunci API',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -609,9 +612,11 @@ const id1: TranslationMap = {
|
||||
'settings.search.statusError': 'Gagal',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API kunci',
|
||||
'settings.search.braveKeyLabel': 'Brave Penelusuran API kunci',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (disimpan)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'Sematan',
|
||||
'settings.embeddings.description':
|
||||
'Pilih penyedia embedding yang mengubah memori menjadi vektor untuk pencarian semantik. Mengubah penyedia, model, atau dimensi membatalkan vektor yang tersimpan dan memerlukan reset memori penuh.',
|
||||
@@ -692,7 +697,7 @@ const id1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'Buka Pengaturan Akun',
|
||||
'channels.localManagedUnavailable': 'Saluran yang dikelola tidak tersedia untuk pengguna lokal.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Jejak navigasi',
|
||||
|
||||
@@ -599,6 +599,9 @@ const it1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Cerca',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'Configurato',
|
||||
'settings.search.statusNeedsKey': 'Richiede la chiave API',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -613,9 +616,11 @@ const it1: TranslationMap = {
|
||||
'settings.search.statusError': 'Non riuscito',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API chiave',
|
||||
'settings.search.braveKeyLabel': 'Brave Cerca chiave API',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (memorizzato)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'Incorporamenti',
|
||||
'settings.embeddings.description':
|
||||
'Scegli il fornitore di embeddings che converte la memoria in vettori per la ricerca semantica. Cambiare fornitore, modello o dimensioni invalida i vettori memorizzati e richiede un reset completo della memoria.',
|
||||
@@ -697,7 +702,7 @@ const it1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'Apri impostazioni account',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Percorso di navigazione',
|
||||
|
||||
@@ -580,6 +580,9 @@ const ko1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave 검색',
|
||||
'settings.search.engineBraveDesc': '직접 Brave 검색 API: 웹, 뉴스, 이미지 및 비디오 도구.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': '구성됨',
|
||||
'settings.search.statusNeedsKey': 'API 키 필요',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -606,9 +609,11 @@ const ko1: TranslationMap = {
|
||||
'Browse and manage Model Context Protocol servers that extend the AI with new tools.',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API 키',
|
||||
'settings.search.braveKeyLabel': 'Brave 검색 API 키',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••(저장됨)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': '임베딩',
|
||||
'settings.embeddings.description':
|
||||
'시맨틱 검색을 위해 메모리를 벡터로 변환할 임베딩 제공자를 선택하세요. 제공자, 모델 또는 차원을 변경하면 저장된 벡터가 무효화되며 전체 메모리 초기화가 필요합니다.',
|
||||
@@ -689,7 +694,7 @@ const ko1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': '계정 설정 열기',
|
||||
'channels.localManagedUnavailable': '로컬 사용자는 관리 채널을 사용할 수 없습니다.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': '이동 경로',
|
||||
|
||||
@@ -604,6 +604,9 @@ const pt1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Pesquisa Brave',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'Configurado',
|
||||
'settings.search.statusNeedsKey': 'Precisa da chave API',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -618,9 +621,11 @@ const pt1: TranslationMap = {
|
||||
'settings.search.statusError': 'Falha',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API chave',
|
||||
'settings.search.braveKeyLabel': 'Brave Pesquisar chave API',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (armazenado)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'Embeddings',
|
||||
'settings.embeddings.description':
|
||||
'Escolha qual provedor de embeddings converte memória em vetores para busca semântica. Alterar o provedor, modelo ou dimensões invalida vetores armazenados e requer uma redefinição completa da memória.',
|
||||
@@ -702,7 +707,7 @@ const pt1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'Abrir configurações de conta',
|
||||
'channels.localManagedUnavailable': 'Managed channels are not available for local users.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Trilha de navegação',
|
||||
|
||||
@@ -594,6 +594,9 @@ const ru1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Поиск',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'Настроено',
|
||||
'settings.search.statusNeedsKey': 'Требуется ключ API',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -608,9 +611,11 @@ const ru1: TranslationMap = {
|
||||
'settings.search.statusError': 'Ошибка',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API ключ',
|
||||
'settings.search.braveKeyLabel': 'Brave Поиск API ключ',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (сохранено)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': 'Эмбеддинги',
|
||||
'settings.embeddings.description':
|
||||
'Выберите провайдера эмбеддингов, который преобразует память в векторы для семантического поиска. Изменение провайдера, модели или размерности делает сохранённые векторы недействительными и требует полного сброса памяти.',
|
||||
@@ -692,7 +697,7 @@ const ru1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': 'Откройте настройки учетной записи.',
|
||||
'channels.localManagedUnavailable': 'Управляемые каналы недоступны для локальных пользователей.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': 'Навигационная цепочка',
|
||||
|
||||
@@ -567,7 +567,7 @@ const zhCN1: TranslationMap = {
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel, Brave, and Querit run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': '搜索引擎',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman 托管',
|
||||
'settings.search.engineManagedDesc':
|
||||
@@ -577,6 +577,9 @@ const zhCN1: TranslationMap = {
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave 搜索',
|
||||
'settings.search.engineBraveDesc': '直接Brave 搜索API:网络、新闻、图像和视频工具。',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': '已配置',
|
||||
'settings.search.statusNeedsKey': '需要 API 密钥',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -591,9 +594,11 @@ const zhCN1: TranslationMap = {
|
||||
'settings.search.statusError': '失败',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API 键',
|
||||
'settings.search.braveKeyLabel': 'Brave 搜索 API 键',
|
||||
'settings.search.queritKeyLabel': 'Querit API 键',
|
||||
'settings.search.placeholderStored': '••••••••(已存储)',
|
||||
'settings.search.placeholderParallel': 'PK_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.embeddings.title': '向量嵌入',
|
||||
'settings.embeddings.description':
|
||||
'选择将记忆转换为语义搜索向量的嵌入提供商。更改提供商、模型或维度会使已存储的向量无效,需要完全重置记忆。',
|
||||
@@ -674,7 +679,7 @@ const zhCN1: TranslationMap = {
|
||||
'rewards.localUnavailableCta': '开设账户设置',
|
||||
'channels.localManagedUnavailable': '本地用户无法使用托管频道。',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'devices.comingSoonDescription':
|
||||
'Device pairing is coming soon. This page will be the home for pairing iPhones and managing connected devices.',
|
||||
'common.breadcrumb': '面包屑导航',
|
||||
|
||||
@@ -744,18 +744,21 @@ const en: TranslationMap = {
|
||||
'settings.search.menuDesc':
|
||||
'Default to OpenHuman-managed search or wire up your own provider with an API key.',
|
||||
'settings.search.description':
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel and Brave run direct from your machine using your API key.',
|
||||
'Pick the search engine the agent uses. Managed uses OpenHuman’s backend (no setup). Parallel, Brave, and Querit run direct from your machine using your API key.',
|
||||
'settings.search.engineAria': 'Search engine',
|
||||
'settings.search.engineManagedLabel': 'OpenHuman Managed',
|
||||
'settings.search.engineManagedDesc':
|
||||
'Default. Routed through the OpenHuman backend — no API key required.',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel or Brave API key to enable web search.',
|
||||
'OpenHuman Managed search is not available for local users. Add your own Parallel, Brave, or Querit API key to enable web search.',
|
||||
'settings.search.engineParallelLabel': 'Parallel',
|
||||
'settings.search.engineParallelDesc':
|
||||
'Direct Parallel API: search, extract, chat, research, enrich, dataset tools.',
|
||||
'settings.search.engineBraveLabel': 'Brave Search',
|
||||
'settings.search.engineBraveDesc': 'Direct Brave Search API: web, news, image, and video tools.',
|
||||
'settings.search.engineQueritLabel': 'Querit',
|
||||
'settings.search.engineQueritDesc':
|
||||
'Direct Querit API: web search with site, time range, country, and language filters.',
|
||||
'settings.search.statusConfigured': 'Configured',
|
||||
'settings.search.statusNeedsKey': 'Needs API key',
|
||||
'settings.search.fallbackToManaged':
|
||||
@@ -770,9 +773,11 @@ const en: TranslationMap = {
|
||||
'settings.search.statusError': 'Failed',
|
||||
'settings.search.parallelKeyLabel': 'Parallel API key',
|
||||
'settings.search.braveKeyLabel': 'Brave Search API key',
|
||||
'settings.search.queritKeyLabel': 'Querit API key',
|
||||
'settings.search.placeholderStored': '•••••••• (stored)',
|
||||
'settings.search.placeholderParallel': 'pk_...',
|
||||
'settings.search.placeholderBrave': 'BSA...',
|
||||
'settings.search.placeholderQuerit': 'Querit API key',
|
||||
'settings.search.allowedSitesLabel': 'Allowed websites',
|
||||
'settings.search.allowedSitesHint':
|
||||
'Hosts the assistant may open and read — via web fetch and the browser tool — one per line, e.g. reuters.com. A host also covers its subdomains. Web search itself is not restricted by this list.',
|
||||
|
||||
@@ -22,7 +22,7 @@ const zhCN: TranslationMap = {
|
||||
'本地登录不会获得奖励、优惠券或推荐积分。若要累计奖励,请先登出,然后使用 OpenHuman 账号登录。',
|
||||
'rewards.localUnavailableCta': '打开账户设置',
|
||||
'settings.search.localManagedUnavailable':
|
||||
'本地用户无法使用 OpenHuman 托管搜索。请添加你自己的 Parallel 或 Brave API 密钥以启用网页搜索。',
|
||||
'本地用户无法使用 OpenHuman 托管搜索。请添加你自己的 Parallel、Brave 或 Querit API 密钥以启用网页搜索。',
|
||||
'devices.comingSoonDescription': '设备配对即将推出。此页面将用于配对 iPhone 并管理已连接设备。',
|
||||
'welcome.continueLocallyExperimental': '本地继续(实验性)',
|
||||
};
|
||||
|
||||
@@ -412,7 +412,7 @@ export async function openhumanGetMeetSettings(): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export type SearchEngineId = 'managed' | 'parallel' | 'brave';
|
||||
export type SearchEngineId = 'managed' | 'parallel' | 'brave' | 'querit';
|
||||
|
||||
export interface SearchSettingsUpdate {
|
||||
engine?: SearchEngineId;
|
||||
@@ -422,6 +422,8 @@ export interface SearchSettingsUpdate {
|
||||
parallel_api_key?: string;
|
||||
/** Empty string clears the stored key. */
|
||||
brave_api_key?: string;
|
||||
/** Empty string clears the stored key. */
|
||||
querit_api_key?: string;
|
||||
/**
|
||||
* Websites the assistant may open/read (web_fetch / curl). Exact hosts
|
||||
* match their subdomains; `"*"` allows all public sites; an empty list
|
||||
@@ -444,6 +446,7 @@ export interface SearchSettings {
|
||||
timeout_secs: number;
|
||||
parallel_configured: boolean;
|
||||
brave_configured: boolean;
|
||||
querit_configured: boolean;
|
||||
/** Current allowed-websites host list (may contain `"*"`). */
|
||||
allowed_domains: string[];
|
||||
/** True when the allowlist contains the `"*"` wildcard. */
|
||||
|
||||
@@ -43,6 +43,7 @@ pub use schema::{
|
||||
WebSearchConfig, WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, MODEL_AGENTIC_V1,
|
||||
MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1,
|
||||
MODEL_SUMMARIZATION_V1, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_MANAGED, SEARCH_ENGINE_PARALLEL,
|
||||
SEARCH_ENGINE_QUERIT,
|
||||
};
|
||||
pub use schema::{
|
||||
clear_active_user, default_projects_dir, default_root_openhuman_dir, pre_login_user_dir,
|
||||
|
||||
@@ -410,8 +410,10 @@ pub struct MeetSettingsPatch {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SearchSettingsPatch {
|
||||
/// One of `managed` | `parallel` | `brave`. Empty string / unknown values
|
||||
/// fall back to `managed` at registration time.
|
||||
/// One of `managed` | `parallel` | `brave` | `querit`.
|
||||
/// Empty/unknown values are rejected by `apply_search_settings`.
|
||||
/// Runtime fallback to `managed` applies only to persisted/legacy config
|
||||
/// values resolved by `SearchConfig::effective_engine()`.
|
||||
pub engine: Option<String>,
|
||||
/// 1..=20. Clamped silently at apply time.
|
||||
pub max_results: Option<usize>,
|
||||
@@ -421,6 +423,8 @@ pub struct SearchSettingsPatch {
|
||||
pub parallel_api_key: Option<String>,
|
||||
/// Brave Search API key. An empty string clears the stored key.
|
||||
pub brave_api_key: Option<String>,
|
||||
/// Querit API key. An empty string clears the stored key.
|
||||
pub querit_api_key: Option<String>,
|
||||
/// Websites the assistant may open/read (`web_fetch` / `curl`), as a
|
||||
/// host allowlist. Entries are exact hosts (`reuters.com`), which also
|
||||
/// match their subdomains, or `"*"` for all public sites. Empty list
|
||||
@@ -1008,12 +1012,12 @@ pub async fn apply_search_settings(
|
||||
// time via `effective_engine()`, but failing fast in the writer keeps
|
||||
// the TOML clean.
|
||||
match trimmed {
|
||||
"managed" | "parallel" | "brave" => {
|
||||
"managed" | "parallel" | "brave" | "querit" => {
|
||||
config.search.engine = trimmed.to_string();
|
||||
}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"engine must be one of managed/parallel/brave (got {other:?})"
|
||||
"engine must be one of managed/parallel/brave/querit (got {other:?})"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1048,6 +1052,14 @@ pub async fn apply_search_settings(
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(raw) = update.querit_api_key {
|
||||
let trimmed = raw.trim();
|
||||
config.search.querit.api_key = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
// Allowed websites (web_fetch / curl host allowlist). Trim + drop blanks
|
||||
// + dedupe so the saved TOML stays clean; `"*"` is preserved as the
|
||||
// allow-all wildcard.
|
||||
@@ -1115,11 +1127,13 @@ pub async fn get_search_settings() -> Result<RpcOutcome<serde_json::Value>, Stri
|
||||
crate::openhuman::config::SearchEngine::Managed => "managed",
|
||||
crate::openhuman::config::SearchEngine::Parallel => "parallel",
|
||||
crate::openhuman::config::SearchEngine::Brave => "brave",
|
||||
crate::openhuman::config::SearchEngine::Querit => "querit",
|
||||
},
|
||||
"max_results": config.search.max_results,
|
||||
"timeout_secs": config.search.timeout_secs,
|
||||
"parallel_configured": config.search.parallel.has_key(),
|
||||
"brave_configured": config.search.brave.has_key(),
|
||||
"querit_configured": config.search.querit.has_key(),
|
||||
"allowed_domains": config.http_request.allowed_domains,
|
||||
"allow_all": config.http_request.allowed_domains.iter().any(|d| d == "*"),
|
||||
});
|
||||
|
||||
@@ -447,7 +447,7 @@ fn decrypt_config_secrets(config: &mut Config, openhuman_dir: &Path) -> Result<(
|
||||
|
||||
decrypt_optional_secret(&store, &mut config.api_key, "api_key")?;
|
||||
|
||||
// Search engines: BYO API keys for Parallel and Brave.
|
||||
// Search engines: BYO API keys for direct providers.
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.search.parallel.api_key,
|
||||
@@ -458,6 +458,11 @@ fn decrypt_config_secrets(config: &mut Config, openhuman_dir: &Path) -> Result<(
|
||||
&mut config.search.brave.api_key,
|
||||
"search.brave.api_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.search.querit.api_key,
|
||||
"search.querit.api_key",
|
||||
)?;
|
||||
|
||||
// Channels: decrypt every optional secret field.
|
||||
//
|
||||
@@ -566,6 +571,11 @@ fn encrypt_config_secrets(config: &mut Config) -> Result<()> {
|
||||
&mut config.search.brave.api_key,
|
||||
"search.brave.api_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.search.querit.api_key,
|
||||
"search.querit.api_key",
|
||||
)?;
|
||||
|
||||
let ch = &mut config.channels_config;
|
||||
if let Some(ref mut tg) = ch.telegram {
|
||||
@@ -1545,6 +1555,11 @@ impl Config {
|
||||
self.search.brave.api_key = Some(key);
|
||||
}
|
||||
}
|
||||
if let Some(key) = env.get_any(&["OPENHUMAN_QUERIT_API_KEY", "QUERIT_API_KEY"]) {
|
||||
if !key.trim().is_empty() {
|
||||
self.search.querit.api_key = Some(key);
|
||||
}
|
||||
}
|
||||
if let Some(max) = env.get_any(&["OPENHUMAN_SEARCH_MAX_RESULTS", "SEARCH_MAX_RESULTS"]) {
|
||||
if let Ok(n) = max.parse::<usize>() {
|
||||
if (1..=20).contains(&n) {
|
||||
|
||||
@@ -84,7 +84,7 @@ pub use tools::{
|
||||
PolymarketClobCredentials, PolymarketConfig, SearchConfig, SearchEngine,
|
||||
SearchEngineCredentials, SearxngConfig, SecretsConfig, SeltzConfig, WebSearchConfig,
|
||||
COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_MANAGED,
|
||||
SEARCH_ENGINE_PARALLEL,
|
||||
SEARCH_ENGINE_PARALLEL, SEARCH_ENGINE_QUERIT,
|
||||
};
|
||||
pub use update::{UpdateConfig, UpdateRestartStrategy};
|
||||
mod voice_server;
|
||||
|
||||
@@ -522,11 +522,12 @@ impl Default for WebSearchConfig {
|
||||
// which tools are registered: `managed` → backend-proxied `web_search`;
|
||||
// `parallel` → direct Parallel API tools (search/extract/chat/research/
|
||||
// enrich/dataset); `brave` → direct Brave Search tools (web/news/
|
||||
// images/videos).
|
||||
// images/videos); `querit` → direct Querit web search.
|
||||
|
||||
pub const SEARCH_ENGINE_MANAGED: &str = "managed";
|
||||
pub const SEARCH_ENGINE_PARALLEL: &str = "parallel";
|
||||
pub const SEARCH_ENGINE_BRAVE: &str = "brave";
|
||||
pub const SEARCH_ENGINE_QUERIT: &str = "querit";
|
||||
|
||||
fn default_search_engine() -> String {
|
||||
SEARCH_ENGINE_MANAGED.into()
|
||||
@@ -572,14 +573,15 @@ impl SearchEngineCredentials {
|
||||
|
||||
/// Unified search-engine configuration. Exactly one engine drives tool
|
||||
/// registration at a time. `managed` is the backend-proxied default and
|
||||
/// requires no key; `parallel` and `brave` are BYO and require their
|
||||
/// requires no key; `parallel`, `brave`, and `querit` are BYO and require their
|
||||
/// own API key in the matching sub-block.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(default)]
|
||||
pub struct SearchConfig {
|
||||
/// Active search engine. One of [`SEARCH_ENGINE_MANAGED`],
|
||||
/// [`SEARCH_ENGINE_PARALLEL`], [`SEARCH_ENGINE_BRAVE`]. Unknown
|
||||
/// values fall back to managed at registration time.
|
||||
/// [`SEARCH_ENGINE_PARALLEL`], [`SEARCH_ENGINE_BRAVE`], or
|
||||
/// [`SEARCH_ENGINE_QUERIT`]. Unknown values fall back to managed at
|
||||
/// registration time.
|
||||
#[serde(default = "default_search_engine")]
|
||||
pub engine: String,
|
||||
|
||||
@@ -598,6 +600,10 @@ pub struct SearchConfig {
|
||||
/// Brave Search credentials (used when `engine = "brave"`).
|
||||
#[serde(default)]
|
||||
pub brave: SearchEngineCredentials,
|
||||
|
||||
/// Querit credentials (used when `engine = "querit"`).
|
||||
#[serde(default)]
|
||||
pub querit: SearchEngineCredentials,
|
||||
}
|
||||
|
||||
impl Default for SearchConfig {
|
||||
@@ -608,6 +614,7 @@ impl Default for SearchConfig {
|
||||
timeout_secs: default_search_timeout_secs(),
|
||||
parallel: SearchEngineCredentials::default(),
|
||||
brave: SearchEngineCredentials::default(),
|
||||
querit: SearchEngineCredentials::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -620,6 +627,7 @@ pub enum SearchEngine {
|
||||
Managed,
|
||||
Parallel,
|
||||
Brave,
|
||||
Querit,
|
||||
}
|
||||
|
||||
impl SearchConfig {
|
||||
@@ -631,6 +639,7 @@ impl SearchConfig {
|
||||
match self.engine.trim().to_ascii_lowercase().as_str() {
|
||||
SEARCH_ENGINE_PARALLEL if self.parallel.has_key() => SearchEngine::Parallel,
|
||||
SEARCH_ENGINE_BRAVE if self.brave.has_key() => SearchEngine::Brave,
|
||||
SEARCH_ENGINE_QUERIT if self.querit.has_key() => SearchEngine::Querit,
|
||||
_ => SearchEngine::Managed,
|
||||
}
|
||||
}
|
||||
@@ -679,6 +688,17 @@ mod search_config_tests {
|
||||
assert_eq!(cfg.effective_engine(), SearchEngine::Brave);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn querit_requires_key() {
|
||||
let mut cfg = SearchConfig {
|
||||
engine: SEARCH_ENGINE_QUERIT.into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(cfg.effective_engine(), SearchEngine::Managed);
|
||||
cfg.querit.api_key = Some("real".into());
|
||||
assert_eq!(cfg.effective_engine(), SearchEngine::Querit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_defaults_to_allow_all() {
|
||||
// Web research works out of the box: the default allowlist is the
|
||||
|
||||
@@ -128,6 +128,7 @@ struct SearchSettingsUpdate {
|
||||
timeout_secs: Option<u64>,
|
||||
parallel_api_key: Option<String>,
|
||||
brave_api_key: Option<String>,
|
||||
querit_api_key: Option<String>,
|
||||
allowed_domains: Option<Vec<String>>,
|
||||
allow_all: Option<bool>,
|
||||
}
|
||||
@@ -816,7 +817,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
inputs: vec![
|
||||
optional_string(
|
||||
"engine",
|
||||
"Active engine: managed | parallel | brave.",
|
||||
"Active engine: managed | parallel | brave | querit.",
|
||||
),
|
||||
FieldSchema {
|
||||
name: "max_results",
|
||||
@@ -838,6 +839,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"brave_api_key",
|
||||
"Brave Search API key (empty string clears the stored key).",
|
||||
),
|
||||
optional_string(
|
||||
"querit_api_key",
|
||||
"Querit API key (empty string clears the stored key).",
|
||||
),
|
||||
FieldSchema {
|
||||
name: "allowed_domains",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
|
||||
@@ -1529,6 +1534,7 @@ fn handle_update_search_settings(params: Map<String, Value>) -> ControllerFuture
|
||||
timeout_secs: update.timeout_secs,
|
||||
parallel_api_key: update.parallel_api_key,
|
||||
brave_api_key: update.brave_api_key,
|
||||
querit_api_key: update.querit_api_key,
|
||||
allowed_domains: update.allowed_domains,
|
||||
allow_all: update.allow_all,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod brave;
|
||||
pub mod client;
|
||||
pub mod google_places;
|
||||
pub mod parallel;
|
||||
pub mod querit;
|
||||
pub mod searxng;
|
||||
pub mod seltz;
|
||||
pub mod stock_prices;
|
||||
@@ -28,6 +29,7 @@ pub use parallel::{
|
||||
ParallelChatTool, ParallelDatasetTool, ParallelEnrichTool, ParallelExtractTool,
|
||||
ParallelResearchTool, ParallelSearchTool,
|
||||
};
|
||||
pub use querit::QueritSearchTool;
|
||||
pub use searxng::{SearxngSearchArgs, SearxngSearchResponse, SearxngSearchTool};
|
||||
pub use seltz::SeltzSearchTool;
|
||||
pub use stock_prices::{
|
||||
|
||||
@@ -0,0 +1,819 @@
|
||||
//! Querit web search integration -- direct API (not backend-proxied).
|
||||
//!
|
||||
//! **Scope**: Agent + CLI/RPC.
|
||||
//!
|
||||
//! **Endpoint**: `POST https://api.querit.ai/v1/search`
|
||||
//!
|
||||
//! **Auth**: `Authorization: Bearer <api key>`.
|
||||
//!
|
||||
//! Querit exposes an AI-oriented web search endpoint with optional site,
|
||||
//! time-range, country, and language filters. This integration calls the
|
||||
//! Querit API directly using the user's configured API key.
|
||||
|
||||
use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_API_URL: &str = "https://api.querit.ai/v1";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct QueritSearchResponse {
|
||||
#[serde(default)]
|
||||
pub took: String,
|
||||
#[serde(default)]
|
||||
pub error_code: i64,
|
||||
#[serde(default)]
|
||||
pub error_msg: String,
|
||||
#[serde(default)]
|
||||
pub search_id: i64,
|
||||
#[serde(default)]
|
||||
pub query_context: QueritQueryContext,
|
||||
#[serde(default)]
|
||||
pub results: QueritResults,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
pub struct QueritQueryContext {
|
||||
#[serde(default)]
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
pub struct QueritResults {
|
||||
#[serde(default)]
|
||||
pub result: Vec<QueritResultItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct QueritResultItem {
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub page_age: Option<String>,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub snippet: Option<String>,
|
||||
#[serde(default)]
|
||||
pub site_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub site_icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sentence: Vec<String>,
|
||||
}
|
||||
|
||||
/// Real-time web search via the Querit API.
|
||||
pub struct QueritSearchTool {
|
||||
tool_name: &'static str,
|
||||
api_key: Option<String>,
|
||||
api_url: String,
|
||||
max_results: usize,
|
||||
timeout_secs: u64,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl QueritSearchTool {
|
||||
pub fn new(
|
||||
api_key: Option<String>,
|
||||
api_url: Option<String>,
|
||||
max_results: usize,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
Self::with_name("querit_search", api_key, api_url, max_results, timeout_secs)
|
||||
}
|
||||
|
||||
pub fn new_web_search_tool(
|
||||
api_key: Option<String>,
|
||||
api_url: Option<String>,
|
||||
max_results: usize,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
Self::with_name(
|
||||
"web_search_tool",
|
||||
api_key,
|
||||
api_url,
|
||||
max_results,
|
||||
timeout_secs,
|
||||
)
|
||||
}
|
||||
|
||||
fn with_name(
|
||||
tool_name: &'static str,
|
||||
api_key: Option<String>,
|
||||
api_url: Option<String>,
|
||||
max_results: usize,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
let timeout = timeout_secs.max(1);
|
||||
let http_client = crate::openhuman::tls::tls_client_builder()
|
||||
.http1_only()
|
||||
.timeout(Duration::from_secs(timeout))
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.expect("failed to build Querit HTTP client");
|
||||
|
||||
Self {
|
||||
tool_name,
|
||||
api_key,
|
||||
api_url: api_url.unwrap_or_else(|| DEFAULT_API_URL.to_string()),
|
||||
max_results: max_results.clamp(1, 20),
|
||||
timeout_secs: timeout,
|
||||
http_client,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_results_plain(&self, results: &[QueritResultItem], query: &str) -> String {
|
||||
if results.is_empty() {
|
||||
return format!("No results found for: {}", query);
|
||||
}
|
||||
|
||||
let mut lines = vec![format!("Search results for: {} (via Querit)", query)];
|
||||
for (i, item) in results.iter().take(self.max_results).enumerate() {
|
||||
let title = item
|
||||
.title
|
||||
.as_deref()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.unwrap_or("Untitled");
|
||||
lines.push(format!("{}. {}", i + 1, title));
|
||||
lines.push(format!(" {}", item.url.trim()));
|
||||
|
||||
if let Some(age) = item.page_age.as_deref() {
|
||||
let age = age.trim();
|
||||
if !age.is_empty() {
|
||||
lines.push(format!(" Page age: {}", age));
|
||||
}
|
||||
}
|
||||
if let Some(site) = item.site_name.as_deref() {
|
||||
let site = site.trim();
|
||||
if !site.is_empty() {
|
||||
lines.push(format!(" Site: {}", site));
|
||||
}
|
||||
}
|
||||
if let Some(snippet) = item.snippet_text() {
|
||||
let truncated = crate::openhuman::util::truncate_with_ellipsis(&snippet, 500);
|
||||
lines.push(format!(" {}", truncated));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn render_results_markdown(&self, results: &[QueritResultItem], query: &str) -> String {
|
||||
if results.is_empty() {
|
||||
return format!("_No results for `{query}`._");
|
||||
}
|
||||
|
||||
let mut out = format!("# Search results -- `{query}`\n");
|
||||
for item in results.iter().take(self.max_results) {
|
||||
let title = item
|
||||
.title
|
||||
.as_deref()
|
||||
.filter(|t| !t.trim().is_empty())
|
||||
.unwrap_or("Untitled");
|
||||
out.push_str(&format!("\n## [{title}]({})\n", item.url.trim()));
|
||||
if let Some(age) = item.page_age.as_deref() {
|
||||
let age = age.trim();
|
||||
if !age.is_empty() {
|
||||
out.push_str(&format!("_Page age: {age}_\n\n"));
|
||||
}
|
||||
}
|
||||
if let Some(site) = item.site_name.as_deref() {
|
||||
let site = site.trim();
|
||||
if !site.is_empty() {
|
||||
out.push_str(&format!("_Site: {site}_\n\n"));
|
||||
}
|
||||
}
|
||||
if let Some(snippet) = item.snippet_text() {
|
||||
let truncated = crate::openhuman::util::truncate_with_suffix(&snippet, 500, "...");
|
||||
out.push_str(&format!("> {truncated}\n"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn insert_array_filter(args: &Value, key: &str, target: &mut Map<String, Value>) {
|
||||
if let Some(value) = args.get(key).filter(|v| v.is_array()) {
|
||||
target.insert("include".to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn object_or_include_map(value: Option<Value>) -> Map<String, Value> {
|
||||
match value {
|
||||
Some(Value::Object(map)) => map,
|
||||
Some(Value::Array(items)) => {
|
||||
let mut map = Map::new();
|
||||
map.insert("include".to_string(), Value::Array(items));
|
||||
map
|
||||
}
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_filters(args: &Value) -> Option<Value> {
|
||||
let mut filters = args
|
||||
.get("filters")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut sites = Self::object_or_include_map(filters.remove("sites"));
|
||||
if let Some(include) = args.get("include_domains").filter(|v| v.is_array()) {
|
||||
sites.insert("include".to_string(), include.clone());
|
||||
}
|
||||
if let Some(exclude) = args.get("exclude_domains").filter(|v| v.is_array()) {
|
||||
sites.insert("exclude".to_string(), exclude.clone());
|
||||
}
|
||||
if !sites.is_empty() {
|
||||
filters.insert("sites".to_string(), Value::Object(sites));
|
||||
}
|
||||
|
||||
let existing_time_range = filters
|
||||
.remove("timeRange")
|
||||
.or_else(|| filters.remove("time_range"));
|
||||
let mut time_range_obj = match existing_time_range.clone() {
|
||||
Some(Value::Object(map)) => map,
|
||||
Some(Value::String(date)) if !date.trim().is_empty() => {
|
||||
let mut map = Map::new();
|
||||
map.insert("date".to_string(), json!(date.trim()));
|
||||
map
|
||||
}
|
||||
_ => Map::new(),
|
||||
};
|
||||
let time_range = args
|
||||
.get("time_range")
|
||||
.or_else(|| args.get("date"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
let from = args
|
||||
.get("from_date")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
let to = args
|
||||
.get("to_date")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
match (from, to) {
|
||||
(Some(from), Some(to)) => Some(format!("{from}to{to}")),
|
||||
(Some(day), None) | (None, Some(day)) => Some(day.to_string()),
|
||||
(None, None) => None,
|
||||
}
|
||||
});
|
||||
if let Some(date) = time_range {
|
||||
time_range_obj.insert("date".to_string(), json!(date));
|
||||
filters.insert("timeRange".to_string(), Value::Object(time_range_obj));
|
||||
} else if !time_range_obj.is_empty() {
|
||||
filters.insert("timeRange".to_string(), Value::Object(time_range_obj));
|
||||
} else if let Some(other) = existing_time_range {
|
||||
filters.insert("timeRange".to_string(), other);
|
||||
}
|
||||
|
||||
let mut geo = match filters.remove("geo") {
|
||||
Some(Value::Object(map)) => map,
|
||||
_ => Map::new(),
|
||||
};
|
||||
let mut countries = Self::object_or_include_map(geo.remove("countries"));
|
||||
Self::insert_array_filter(args, "countries", &mut countries);
|
||||
if !countries.is_empty() {
|
||||
geo.insert("countries".to_string(), Value::Object(countries));
|
||||
}
|
||||
if !geo.is_empty() {
|
||||
filters.insert("geo".to_string(), Value::Object(geo));
|
||||
}
|
||||
|
||||
let mut languages = Self::object_or_include_map(filters.remove("languages"));
|
||||
Self::insert_array_filter(args, "languages", &mut languages);
|
||||
if !languages.is_empty() {
|
||||
filters.insert("languages".to_string(), Value::Object(languages));
|
||||
}
|
||||
|
||||
if filters.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(filters))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_response(value: Value) -> anyhow::Result<QueritSearchResponse> {
|
||||
let payload = value
|
||||
.get("response_data")
|
||||
.and_then(|v| v.get("aiapi_res"))
|
||||
.or_else(|| value.get("aiapi_res"))
|
||||
.cloned()
|
||||
.unwrap_or(value);
|
||||
serde_json::from_value(payload).map_err(|e| {
|
||||
tracing::warn!("[querit] failed to parse response: {e}");
|
||||
anyhow::anyhow!("Failed to parse Querit response: {e}")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl QueritResultItem {
|
||||
fn snippet_text(&self) -> Option<String> {
|
||||
self.snippet
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
let joined = self
|
||||
.sentence
|
||||
.iter()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if joined.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(joined)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for QueritSearchTool {
|
||||
fn name(&self) -> &str {
|
||||
self.tool_name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search the web in real time using Querit. Returns current results with URLs, \
|
||||
snippets, site names, and page age. Supports site include/exclude filters, \
|
||||
time ranges, countries, and languages."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query. Use concise keywords for best results."
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return (default from config, max 20)."
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Querit-native alias for max_results."
|
||||
},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"description": "Querit-native filters object with sites, timeRange, geo, and languages."
|
||||
},
|
||||
"include_domains": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Only fetch results from these domains."
|
||||
},
|
||||
"exclude_domains": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Exclude results from these domains."
|
||||
},
|
||||
"time_range": {
|
||||
"type": "string",
|
||||
"description": "Querit date filter: d7, w2, m6, y1, or YYYY-MM-DDtoYYYY-MM-DD."
|
||||
},
|
||||
"from_date": {
|
||||
"type": "string",
|
||||
"description": "Start date for a Querit date-range filter (YYYY-MM-DD)."
|
||||
},
|
||||
"to_date": {
|
||||
"type": "string",
|
||||
"description": "End date for a Querit date-range filter (YYYY-MM-DD)."
|
||||
},
|
||||
"countries": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Country filters, e.g. [\"united states\", \"japan\"]."
|
||||
},
|
||||
"languages": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Language filters, e.g. [\"english\", \"japanese\"]."
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
})
|
||||
}
|
||||
|
||||
fn supports_markdown(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
||||
self.execute_with_options(args, ToolCallOptions::default())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_with_options(
|
||||
&self,
|
||||
args: serde_json::Value,
|
||||
options: ToolCallOptions,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
let query = args
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|q| !q.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: query"))?;
|
||||
|
||||
let api_key = self.api_key.as_deref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Querit search unavailable: no API key configured. \
|
||||
Set QUERIT_API_KEY or OPENHUMAN_QUERIT_API_KEY, \
|
||||
or add search.querit.api_key to config.toml."
|
||||
)
|
||||
})?;
|
||||
|
||||
let max_results = args
|
||||
.get("max_results")
|
||||
.or_else(|| args.get("count"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n.clamp(1, 20) as usize)
|
||||
.unwrap_or(self.max_results);
|
||||
|
||||
let mut body = json!({
|
||||
"query": query,
|
||||
"count": max_results,
|
||||
});
|
||||
if let Some(filters) = Self::build_filters(&args) {
|
||||
body.as_object_mut()
|
||||
.expect("querit request body object")
|
||||
.insert("filters".to_string(), filters);
|
||||
}
|
||||
|
||||
let url = format!("{}/search", self.api_url.trim_end_matches('/'));
|
||||
tracing::debug!(
|
||||
query_len = query.chars().count(),
|
||||
max_results,
|
||||
timeout_secs = self.timeout_secs,
|
||||
"[querit] POST {url}"
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.bearer_auth(api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!("[querit] request failed: {e}");
|
||||
anyhow::anyhow!("Querit search request failed: {e}")
|
||||
})?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
status = %status,
|
||||
body_len = body_text.len(),
|
||||
"[querit] non-2xx response from Querit"
|
||||
);
|
||||
anyhow::bail!("Querit returned non-2xx status {status}");
|
||||
}
|
||||
|
||||
let search_resp = Self::decode_response(resp.json().await.map_err(|e| {
|
||||
tracing::warn!("[querit] failed to read response JSON: {e}");
|
||||
anyhow::anyhow!("Failed to read Querit response JSON: {e}")
|
||||
})?)?;
|
||||
|
||||
if search_resp.error_code != 0 && search_resp.error_code != 200 {
|
||||
tracing::warn!(
|
||||
error_code = search_resp.error_code,
|
||||
error_msg_len = search_resp.error_msg.chars().count(),
|
||||
"[querit] application-level error from Querit"
|
||||
);
|
||||
anyhow::bail!("Querit returned error_code {}", search_resp.error_code);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
result_count = search_resp.results.result.len(),
|
||||
search_id = search_resp.search_id,
|
||||
"[querit] search complete"
|
||||
);
|
||||
|
||||
let mut result =
|
||||
ToolResult::success(self.render_results_plain(&search_resp.results.result, query));
|
||||
if options.prefer_markdown {
|
||||
result.markdown_formatted =
|
||||
Some(self.render_results_markdown(&search_resp.results.result, query));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool() -> QueritSearchTool {
|
||||
QueritSearchTool::new(None, None, 5, 15)
|
||||
}
|
||||
|
||||
fn tool_with_key() -> QueritSearchTool {
|
||||
QueritSearchTool::new(Some("test-key".into()), None, 5, 15)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_name() {
|
||||
assert_eq!(tool().name(), "querit_search");
|
||||
assert_eq!(
|
||||
QueritSearchTool::new_web_search_tool(None, None, 5, 15).name(),
|
||||
"web_search_tool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parameters_schema() {
|
||||
let schema = tool().parameters_schema();
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema["properties"]["query"].is_object());
|
||||
assert!(schema["properties"]["time_range"].is_object());
|
||||
assert!(schema["properties"]["countries"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_plain_with_data() {
|
||||
let results = vec![QueritResultItem {
|
||||
url: "https://example.com/a".into(),
|
||||
page_age: Some("2026-05-01 00:00:00".into()),
|
||||
title: Some("First Result".into()),
|
||||
snippet: Some("First result snippet.".into()),
|
||||
site_name: Some("Example".into()),
|
||||
site_icon: None,
|
||||
sentence: vec![],
|
||||
}];
|
||||
|
||||
let result = tool().render_results_plain(&results, "test");
|
||||
assert!(result.contains("via Querit"));
|
||||
assert!(result.contains("First Result"));
|
||||
assert!(result.contains("https://example.com/a"));
|
||||
assert!(result.contains("Page age: 2026-05-01"));
|
||||
assert!(result.contains("Site: Example"));
|
||||
assert!(result.contains("First result snippet."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_filters_maps_supported_fields() {
|
||||
let filters = QueritSearchTool::build_filters(&json!({
|
||||
"include_domains": ["example.com"],
|
||||
"exclude_domains": ["spam.test"],
|
||||
"time_range": "d7",
|
||||
"countries": ["united states"],
|
||||
"languages": ["english"]
|
||||
}))
|
||||
.expect("filters");
|
||||
|
||||
assert_eq!(filters["sites"]["include"][0], "example.com");
|
||||
assert_eq!(filters["sites"]["exclude"][0], "spam.test");
|
||||
assert_eq!(filters["timeRange"]["date"], "d7");
|
||||
assert_eq!(filters["geo"]["countries"]["include"][0], "united states");
|
||||
assert_eq!(filters["languages"]["include"][0], "english");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_filters_preserves_native_filters_payload() {
|
||||
let filters = QueritSearchTool::build_filters(&json!({
|
||||
"filters": {
|
||||
"sites": {
|
||||
"include": ["techcrunch.com"]
|
||||
},
|
||||
"timeRange": {
|
||||
"date": "m3"
|
||||
},
|
||||
"geo": {
|
||||
"countries": {
|
||||
"include": ["united states"]
|
||||
}
|
||||
},
|
||||
"languages": {
|
||||
"include": ["english"]
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("filters");
|
||||
|
||||
assert_eq!(filters["sites"]["include"][0], "techcrunch.com");
|
||||
assert_eq!(filters["timeRange"]["date"], "m3");
|
||||
assert_eq!(filters["geo"]["countries"]["include"][0], "united states");
|
||||
assert_eq!(filters["languages"]["include"][0], "english");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_filters_normalizes_native_shorthand_values() {
|
||||
let filters = QueritSearchTool::build_filters(&json!({
|
||||
"filters": {
|
||||
"sites": ["example.com"],
|
||||
"time_range": "m3",
|
||||
"geo": {
|
||||
"countries": ["united states"]
|
||||
},
|
||||
"languages": ["english"]
|
||||
}
|
||||
}))
|
||||
.expect("filters");
|
||||
|
||||
assert_eq!(filters["sites"]["include"][0], "example.com");
|
||||
assert_eq!(filters["timeRange"]["date"], "m3");
|
||||
assert_eq!(filters["geo"]["countries"]["include"][0], "united states");
|
||||
assert_eq!(filters["languages"]["include"][0], "english");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_filters_combines_date_range() {
|
||||
let filters = QueritSearchTool::build_filters(&json!({
|
||||
"from_date": "2026-01-01",
|
||||
"to_date": "2026-01-31"
|
||||
}))
|
||||
.expect("filters");
|
||||
assert_eq!(filters["timeRange"]["date"], "2026-01-01to2026-01-31");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_response_accepts_wrapped_aiapi_shape_and_sentence() {
|
||||
let parsed = QueritSearchTool::decode_response(json!({
|
||||
"response_data": {
|
||||
"aiapi_res": {
|
||||
"error_code": 0,
|
||||
"search_id": 42,
|
||||
"results": {
|
||||
"result": [
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"title": "Wrapped",
|
||||
"sentence": ["Sentence excerpt."]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.expect("wrapped response");
|
||||
|
||||
assert_eq!(parsed.results.result[0].title.as_deref(), Some("Wrapped"));
|
||||
assert_eq!(
|
||||
parsed.results.result[0].snippet_text().as_deref(),
|
||||
Some("Sentence excerpt.")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_missing_query() {
|
||||
let result = tool_with_key().execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_without_api_key() {
|
||||
let result = tool().execute(json!({"query": "test"})).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("no API key configured"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_posts_to_querit_and_renders_results() {
|
||||
use axum::{extract::Json, routing::post, Router};
|
||||
use serde_json::Value;
|
||||
|
||||
let app = Router::new().route(
|
||||
"/search",
|
||||
post(|Json(body): Json<Value>| async move {
|
||||
assert_eq!(body["query"], "test query");
|
||||
assert_eq!(body["count"], 3);
|
||||
assert_eq!(body["filters"]["sites"]["include"][0], "example.com");
|
||||
Json(json!({
|
||||
"took": "12ms",
|
||||
"error_code": 200,
|
||||
"error_msg": "",
|
||||
"search_id": 42,
|
||||
"query_context": { "query": "test query" },
|
||||
"results": {
|
||||
"result": [
|
||||
{
|
||||
"url": "https://example.com/result",
|
||||
"title": "Querit Result",
|
||||
"snippet": "Content from Querit search.",
|
||||
"page_age": "2026-05-01 00:00:00",
|
||||
"site_name": "Example"
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
}),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
||||
|
||||
let tool = QueritSearchTool::new(Some("test-key".into()), Some(base_url), 5, 15);
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"query": "test query",
|
||||
"max_results": 3,
|
||||
"include_domains": ["example.com"]
|
||||
}))
|
||||
.await
|
||||
.expect("execute() should succeed");
|
||||
|
||||
assert!(result.output().contains("Querit Result"));
|
||||
assert!(result.output().contains("https://example.com/result"));
|
||||
assert!(result.output().contains("Content from Querit search."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_non_success_status_does_not_expose_response_body() {
|
||||
use axum::{http::StatusCode, routing::post, Router};
|
||||
|
||||
let app = Router::new().route(
|
||||
"/search",
|
||||
post(|| async {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"sensitive query context should stay private",
|
||||
)
|
||||
}),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
||||
|
||||
let tool = QueritSearchTool::new(Some("test-key".into()), Some(base_url), 5, 15);
|
||||
let err = tool
|
||||
.execute(json!({
|
||||
"query": "private search",
|
||||
"max_results": 3
|
||||
}))
|
||||
.await
|
||||
.expect_err("non-2xx responses should fail");
|
||||
let message = err.to_string();
|
||||
|
||||
assert!(message.contains("Querit returned non-2xx status 400 Bad Request"));
|
||||
assert!(!message.contains("sensitive query context"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_app_error_does_not_expose_error_msg() {
|
||||
use axum::{extract::Json, routing::post, Router};
|
||||
use serde_json::Value;
|
||||
|
||||
let app = Router::new().route(
|
||||
"/search",
|
||||
post(|Json(_body): Json<Value>| async move {
|
||||
Json(json!({
|
||||
"took": "3ms",
|
||||
"error_code": 400,
|
||||
"error_msg": "validation failed for sensitive query context",
|
||||
"search_id": 42,
|
||||
"query_context": { "query": "sensitive query context" },
|
||||
"results": { "result": [] }
|
||||
}))
|
||||
}),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let base_url = format!("http://127.0.0.1:{}", addr.port());
|
||||
|
||||
let tool = QueritSearchTool::new(Some("test-key".into()), Some(base_url), 5, 15);
|
||||
let err = tool
|
||||
.execute(json!({
|
||||
"query": "sensitive query context",
|
||||
"max_results": 3
|
||||
}))
|
||||
.await
|
||||
.expect_err("application-level errors should fail");
|
||||
let message = err.to_string();
|
||||
|
||||
assert_eq!(message, "Querit returned error_code 400");
|
||||
assert!(!message.contains("validation failed"));
|
||||
assert!(!message.contains("sensitive query context"));
|
||||
}
|
||||
}
|
||||
@@ -338,13 +338,14 @@ pub fn all_tools_with_runtime(
|
||||
//
|
||||
// Exactly one engine drives `web_search_tool` plus any
|
||||
// engine-specific tools (Parallel research/extract/etc., Brave
|
||||
// news/image/video). Mirrors the LLM-provider API-key model: a
|
||||
// single switch, BYO credentials, layered tool surface.
|
||||
// news/image/video, Querit advanced filters). Mirrors the
|
||||
// LLM-provider API-key model: a single switch, BYO credentials,
|
||||
// layered tool surface.
|
||||
//
|
||||
// Legacy `seltz` / `searxng` config blocks are still parsed but
|
||||
// no longer register tools — they were superseded by this
|
||||
// selector. Use `search.engine = "managed" | "parallel" | "brave"`
|
||||
// instead.
|
||||
// selector. Use `search.engine = "managed" | "parallel" | "brave"
|
||||
// | "querit"` instead.
|
||||
{
|
||||
use crate::openhuman::config::SearchEngine;
|
||||
let search = &root_config.search;
|
||||
@@ -448,6 +449,25 @@ pub fn all_tools_with_runtime(
|
||||
),
|
||||
));
|
||||
}
|
||||
SearchEngine::Querit => {
|
||||
tracing::debug!("[search] active engine = querit (BYO direct API)");
|
||||
tools.push(Box::new(
|
||||
crate::openhuman::integrations::QueritSearchTool::new_web_search_tool(
|
||||
search.querit.api_key.clone(),
|
||||
None,
|
||||
max_results,
|
||||
timeout_secs,
|
||||
),
|
||||
));
|
||||
tools.push(Box::new(
|
||||
crate::openhuman::integrations::QueritSearchTool::new(
|
||||
search.querit.api_key.clone(),
|
||||
None,
|
||||
max_results,
|
||||
timeout_secs,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -993,6 +993,32 @@ fn all_tools_registers_brave_engine_lsp_and_tool_stats_when_enabled() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tools_registers_querit_engine_when_enabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let security = Arc::new(SecurityPolicy::default());
|
||||
let mem = test_memory(&tmp);
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
let mut cfg = test_config(&tmp);
|
||||
cfg.search.engine = crate::openhuman::config::SEARCH_ENGINE_QUERIT.into();
|
||||
cfg.search.querit.api_key = Some("test-querit-key".into());
|
||||
|
||||
let tools = all_tools(
|
||||
Arc::new(cfg.clone()),
|
||||
&security,
|
||||
AuditLogger::disabled(),
|
||||
mem,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
&cfg,
|
||||
);
|
||||
let names = tool_names(&tools);
|
||||
assert_contains_all(&names, &["web_search_tool", "querit_search"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_tools_executes_apify_family_against_fake_backend() {
|
||||
let backend = integration_test_support::spawn_fake_integration_backend().await;
|
||||
|
||||
@@ -20,6 +20,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
tools_schemas("tools_composio_execute"),
|
||||
tools_schemas("tools_web_search"),
|
||||
tools_schemas("tools_seltz_search"),
|
||||
tools_schemas("tools_querit_search"),
|
||||
tools_schemas("tools_searxng_search"),
|
||||
tools_schemas("tools_apify_linkedin_scrape"),
|
||||
tools_schemas("tools_polymarket_execute"),
|
||||
@@ -40,6 +41,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
schema: tools_schemas("tools_seltz_search"),
|
||||
handler: handle_seltz_search,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: tools_schemas("tools_querit_search"),
|
||||
handler: handle_querit_search,
|
||||
},
|
||||
RegisteredController {
|
||||
schema: tools_schemas("tools_searxng_search"),
|
||||
handler: handle_searxng_search,
|
||||
@@ -200,6 +205,96 @@ pub fn tools_schemas(function: &str) -> ControllerSchema {
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"tools_querit_search" => ControllerSchema {
|
||||
namespace: "tools",
|
||||
function: "querit_search",
|
||||
description: "Web search via the Querit API. Returns current results with URLs, \
|
||||
snippets, site names, and page age. Supports site filters, \
|
||||
time ranges, country filters, and language filters.",
|
||||
inputs: vec![
|
||||
FieldSchema {
|
||||
name: "query",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Search query string.",
|
||||
required: true,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "max_results",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Max results (1-20, default 10).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "count",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
|
||||
comment: "Querit-native alias for max_results.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "filters",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
|
||||
comment:
|
||||
"Querit-native filters object with sites, timeRange, geo, and languages.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "include_domains",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
|
||||
TypeSchema::String,
|
||||
)))),
|
||||
comment: "Only fetch results from these domains.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "exclude_domains",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
|
||||
TypeSchema::String,
|
||||
)))),
|
||||
comment: "Exclude results from these domains.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "time_range",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Querit date filter: d7, w2, m6, y1, or YYYY-MM-DDtoYYYY-MM-DD.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "from_date",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "Start date for a Querit date-range filter (YYYY-MM-DD).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "to_date",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
|
||||
comment: "End date for a Querit date-range filter (YYYY-MM-DD).",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "countries",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
|
||||
TypeSchema::String,
|
||||
)))),
|
||||
comment: "Country filters, e.g. united states, japan, germany.",
|
||||
required: false,
|
||||
},
|
||||
FieldSchema {
|
||||
name: "languages",
|
||||
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(
|
||||
TypeSchema::String,
|
||||
)))),
|
||||
comment: "Language filters, e.g. english, japanese, german.",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
outputs: vec![FieldSchema {
|
||||
name: "results",
|
||||
ty: TypeSchema::String,
|
||||
comment: "Formatted Querit search results.",
|
||||
required: true,
|
||||
}],
|
||||
},
|
||||
"tools_searxng_search" => ControllerSchema {
|
||||
namespace: "tools",
|
||||
function: "searxng_search",
|
||||
@@ -514,6 +609,87 @@ fn handle_seltz_search(params: Map<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_querit_search(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let query = params
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| "missing or empty `query`".to_string())?;
|
||||
let max_results = params
|
||||
.get("max_results")
|
||||
.or_else(|| params.get("count"))
|
||||
.and_then(Value::as_u64)
|
||||
.map(|n| n.clamp(1, 20) as usize)
|
||||
.unwrap_or(10);
|
||||
|
||||
let config = config_rpc::load_config_with_timeout().await?;
|
||||
if !config.search.querit.has_key() {
|
||||
tracing::debug!("[rpc][tools.querit_search] querit not configured — rejecting");
|
||||
return Err("Querit search is not enabled. Set QUERIT_API_KEY to enable.".to_string());
|
||||
}
|
||||
|
||||
let has_include_domains = params.get("include_domains").is_some();
|
||||
let has_exclude_domains = params.get("exclude_domains").is_some();
|
||||
let has_time_range = params.get("time_range").is_some();
|
||||
let has_countries = params.get("countries").is_some();
|
||||
let has_languages = params.get("languages").is_some();
|
||||
let has_native_filters = params.get("filters").is_some();
|
||||
|
||||
tracing::debug!(
|
||||
query_len = query.chars().count(),
|
||||
max_results,
|
||||
has_include_domains,
|
||||
has_exclude_domains,
|
||||
has_time_range,
|
||||
has_countries,
|
||||
has_languages,
|
||||
has_native_filters,
|
||||
"[rpc][tools.querit_search] start"
|
||||
);
|
||||
|
||||
let tool = crate::openhuman::integrations::QueritSearchTool::new(
|
||||
config.search.querit.api_key.clone(),
|
||||
None,
|
||||
max_results,
|
||||
config.search.timeout_secs,
|
||||
);
|
||||
|
||||
let mut args = json!({ "query": query, "max_results": max_results });
|
||||
let args_map = args.as_object_mut().unwrap();
|
||||
for key in [
|
||||
"count",
|
||||
"filters",
|
||||
"include_domains",
|
||||
"exclude_domains",
|
||||
"time_range",
|
||||
"from_date",
|
||||
"to_date",
|
||||
"countries",
|
||||
"languages",
|
||||
] {
|
||||
if let Some(v) = params.get(key) {
|
||||
args_map.insert(key.to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let result = tool
|
||||
.execute(args)
|
||||
.await
|
||||
.map_err(|e| format!("querit search failed: {e:#}"))?;
|
||||
|
||||
let payload = json!({ "results": result.output() });
|
||||
let log = vec![format!(
|
||||
"[rpc][tools.querit_search] success query_len={} max_results={}",
|
||||
query.chars().count(),
|
||||
max_results
|
||||
)];
|
||||
RpcOutcome::new(payload, log).into_cli_compatible_json()
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_searxng_search(params: Map<String, Value>) -> ControllerFuture {
|
||||
Box::pin(async move {
|
||||
let query = params
|
||||
@@ -713,13 +889,13 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_schemas_returns_six() {
|
||||
assert_eq!(all_controller_schemas().len(), 6);
|
||||
fn all_schemas_returns_seven() {
|
||||
assert_eq!(all_controller_schemas().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_controllers_returns_six() {
|
||||
assert_eq!(all_registered_controllers().len(), 6);
|
||||
fn all_controllers_returns_seven() {
|
||||
assert_eq!(all_registered_controllers().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -751,6 +927,22 @@ mod tests {
|
||||
assert!(s.inputs.iter().any(|f| f.name == "scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn querit_search_schema_shape() {
|
||||
let s = tools_schemas("tools_querit_search");
|
||||
assert_eq!(s.namespace, "tools");
|
||||
assert_eq!(s.function, "querit_search");
|
||||
assert!(s.inputs.iter().any(|f| f.name == "query" && f.required));
|
||||
assert!(s.inputs.iter().any(|f| f.name == "filters"));
|
||||
assert!(s.inputs.iter().any(|f| f.name == "count"));
|
||||
assert!(s.inputs.iter().any(|f| f.name == "include_domains"));
|
||||
assert!(s.inputs.iter().any(|f| f.name == "time_range"));
|
||||
assert!(s.inputs.iter().any(|f| f.name == "from_date"));
|
||||
assert!(s.inputs.iter().any(|f| f.name == "to_date"));
|
||||
assert!(s.inputs.iter().any(|f| f.name == "countries"));
|
||||
assert!(s.inputs.iter().any(|f| f.name == "languages"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn searxng_search_schema_shape() {
|
||||
let s = tools_schemas("tools_searxng_search");
|
||||
|
||||
Reference in New Issue
Block a user