feat: add web search tool with reranking support
Integrates SearXNG web search as a tool available during chat when the agent has search enabled. Supports optional reranking of results via a configurable reranking model. Replaces Python subprocess evaluation with @pydantic/monty WASM runtime. Introduces stable tool call ID mapping to avoid exposing provider-native IDs to the database.
This commit is contained in:
@@ -6,6 +6,7 @@ import type { Agent } from '~/composables/useAgents';
|
||||
import type FileSelector from './FileSelector.vue';
|
||||
|
||||
const { allModels } = await useModels();
|
||||
const { updateAgent, patchAgentLocally } = await useAgents();
|
||||
|
||||
const inputHeight: Ref<string> = ref('auto');
|
||||
const fileSelectorRef = ref<InstanceType<typeof FileSelector> | null>(null);
|
||||
@@ -39,6 +40,35 @@ const props = defineProps<{
|
||||
providers?: ProviderWithModels[];
|
||||
}>();
|
||||
|
||||
const searchConfig = ref({
|
||||
enabled: props.agent?.config?.search?.enabled ?? false,
|
||||
maxResults: props.agent?.config?.search?.maxResults ?? 10,
|
||||
rerank: props.agent?.config?.search?.rerank ?? false,
|
||||
});
|
||||
|
||||
watch(() => props.agent?.config?.search, (val) => {
|
||||
searchConfig.value = {
|
||||
enabled: val?.enabled ?? false,
|
||||
maxResults: val?.maxResults ?? 10,
|
||||
rerank: val?.rerank ?? false,
|
||||
};
|
||||
}, { deep: true });
|
||||
|
||||
const saveSearchConfig = async () => {
|
||||
if (!props.agent) return;
|
||||
const currentConfig = props.agent.config ?? {};
|
||||
const newConfig = {
|
||||
...currentConfig,
|
||||
search: {
|
||||
enabled: searchConfig.value.enabled,
|
||||
maxResults: searchConfig.value.maxResults,
|
||||
rerank: searchConfig.value.rerank,
|
||||
},
|
||||
};
|
||||
patchAgentLocally(props.agent.id, { config: newConfig });
|
||||
updateAgent(props.agent.id, { config: newConfig });
|
||||
};
|
||||
|
||||
const selectedModel = ref<ModelWithProvider | null>(null);
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
@@ -165,25 +195,21 @@ const handleWindowKeyDown = async (event: KeyboardEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(textAreaValue, async () => {
|
||||
const resizeTextArea = () => {
|
||||
const textarea = inputRef.value;
|
||||
if (!textarea) return;
|
||||
|
||||
inputHeight.value = 'auto';
|
||||
await nextTick();
|
||||
nextTick().then(() => {
|
||||
const lineHeight = 24;
|
||||
const maxLines = 10;
|
||||
const maxHeight = maxLines * lineHeight;
|
||||
const height = Math.min(textarea.scrollHeight, maxHeight);
|
||||
inputHeight.value = `${height}px`;
|
||||
});
|
||||
};
|
||||
|
||||
const lineHeight = 24;
|
||||
const maxLines = 10;
|
||||
const maxHeight = maxLines * lineHeight;
|
||||
|
||||
const newHeight = textarea.scrollHeight;
|
||||
|
||||
if (newHeight > maxHeight) {
|
||||
inputHeight.value = `${maxHeight}px`;
|
||||
} else {
|
||||
inputHeight.value = `${newHeight}px`;
|
||||
}
|
||||
}, { immediate: true });
|
||||
watch(textAreaValue, resizeTextArea, { immediate: true });
|
||||
|
||||
let hasCommandKey = false;
|
||||
if (import.meta.server) {
|
||||
@@ -197,14 +223,26 @@ onBeforeMount(() => {
|
||||
tempInput = (document.getElementById('chat') as HTMLInputElement)?.value ?? '';
|
||||
});
|
||||
|
||||
let resizeRafId: number | undefined;
|
||||
const handleWindowResize = () => {
|
||||
if (resizeRafId) return;
|
||||
resizeRafId = requestAnimationFrame(() => {
|
||||
resizeRafId = undefined;
|
||||
resizeTextArea();
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
textAreaValue.value = tempInput;
|
||||
document.addEventListener('keydown', handleWindowKeyDown);
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
inputRef.value?.addEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleWindowKeyDown);
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
if (resizeRafId !== undefined) cancelAnimationFrame(resizeRafId);
|
||||
inputRef.value?.removeEventListener('paste', handlePaste);
|
||||
});
|
||||
</script>
|
||||
@@ -232,6 +270,11 @@ onUnmounted(() => {
|
||||
<div class="flex flex-1 gap-1 min-w-0">
|
||||
<ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel"
|
||||
:providers="providers" />
|
||||
<SearchSelector v-if="selectedModel?.capabilities.includes('tools')" :enabled="searchConfig.enabled"
|
||||
:max-results="searchConfig.maxResults" :rerank="searchConfig.rerank"
|
||||
@update:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig() }"
|
||||
@update:max-results="(v: number) => { searchConfig.maxResults = v; saveSearchConfig() }"
|
||||
@update:rerank="(v: boolean) => { searchConfig.rerank = v; saveSearchConfig() }" />
|
||||
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" />
|
||||
</div>
|
||||
<button aria-label="Send message" @click="handleSubmit"
|
||||
|
||||
Reference in New Issue
Block a user