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:
Zoe
2026-04-27 12:05:47 -05:00
parent 90d5698e76
commit 9914c043cb
6 changed files with 490 additions and 136 deletions
+57 -14
View File
@@ -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"
+140
View File
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { DialogType } from '~/composables/useDialog';
const { openDialog } = await useDialog();
const props = defineProps<{
enabled: boolean;
maxResults: number;
rerank: boolean;
}>();
const emit = defineEmits<{
'update:enabled': [value: boolean];
'update:maxResults': [value: number];
'update:rerank': [value: boolean];
}>();
const adjustMaxResults = (delta: number) => {
const next = Math.min(50, Math.max(1, props.maxResults + delta));
emit('update:maxResults', next);
};
const { settings } = await useUserSettings();
const systemAssistantsRerank = computed(() => {
return settings.value.systemAssistants?.rerank ?? null;
});
const isRerankConfigured = computed(() => {
const sa = systemAssistantsRerank.value;
return sa?.enabled === true && sa?.modelId != null && sa.modelId !== '';
});
const openSettings = () => {
openDialog(DialogType.Settings, undefined, { page: 'systemAssistants' });
};
</script>
<template>
<Dropdown dropdownClass="text-sm" placement="top">
<template #default="{ toggle, setRef }">
<button :ref="setRef" @click="toggle"
class="flex items-center justify-center h-8.5 w-8.5 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="pointer-events-none i-tabler-world text-5 transition-colors duration-200"
:class="enabled ? 'text-[var(--color-accent)]' : 'text-[var(--text-secondary)]'"></span>
</button>
</template>
<template #dropdown="{ close }">
<div class="flex p-1 gap-4">
<!-- Left: mode toggle group -->
<div class="flex flex-col gap-1">
<button @click="emit('update:enabled', false)"
class="flex items-start gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150"
:class="!enabled
? 'bg-[var(--color-active)]'
: '@hover:bg-[var(--color-hover)]'">
<span class="i-tabler-world-off text-5 mt-0.5 shrink-0"
:class="!enabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-dim)]'"></span>
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium"
:class="!enabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
Off
</span>
<span class="text-xs leading-snug"
:class="!enabled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-dim)]'">
Disable web access
</span>
</div>
</button>
<button @click="emit('update:enabled', true)"
class="flex items-start gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150"
:class="enabled
? 'bg-[var(--color-active)]'
: '@hover:bg-[var(--color-hover)]'">
<span class="i-tabler-world text-5 mt-0.5 shrink-0"
:class="enabled ? 'text-[var(--color-accent)]' : 'text-[var(--text-dim)]'"></span>
<div class="flex flex-col gap-0.5">
<span class="text-sm font-medium"
:class="enabled ? 'text-[var(--text-primary)]' : 'text-[var(--text-secondary)]'">
Auto
</span>
<span class="text-xs leading-snug"
:class="enabled ? 'text-[var(--text-secondary)]' : 'text-[var(--text-dim)]'">
Search the web automatically when needed
</span>
</div>
</button>
</div>
<!-- Right: settings (only when enabled) -->
<div v-if="enabled" class="flex flex-col gap-3 border-l border-[var(--color-border)] pl-4">
<!-- Max results stepper -->
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-[var(--text-tertiary)] uppercase tracking-wider">
Max results
</span>
<div class="flex items-center gap-2">
<button @click="adjustMaxResults(-1)"
class="h-7 w-7 flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="text-sm i-mynaui-minus"></span>
</button>
<span class="w-8 text-center text-sm font-medium tabular-nums text-[var(--text-primary)]">
{{ maxResults }}
</span>
<button @click="adjustMaxResults(1)"
class="h-7 w-7 flex items-center justify-center rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)] @hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="text-sm i-mynaui-plus"></span>
</button>
</div>
</div>
<!-- Rerank toggle -->
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium text-[var(--color-tertiary)] uppercase tracking-wider">
Rerank
</span>
<div v-if="!isRerankConfigured"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)]">
<button @click="() => { openSettings(); close() }"
class="flex items-center gap-2 text-xs text-[var(--text-dim)] hover:text-[var(--text-primary)] transition-colors duration-150">
<span class="i-mynaui-sparkles text-3.5"></span>
<span>Setup reranking</span>
<span class="i-mynaui-arrow-right text-3 ml-auto"></span>
</button>
</div>
<label v-else
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-[var(--bg-container)] border border-[var(--color-border)] cursor-pointer @hover:border-[var(--color-hover)] transition-colors duration-150">
<input type="checkbox" :checked="props.rerank"
@change="emit('update:rerank', ($event.target as HTMLInputElement).checked)"
class="w-4 h-4 rounded border-[var(--color-border)] bg-transparent text-[var(--color-accent)] focus:ring-[var(--color-accent)] focus:ring-offset-0 cursor-pointer" />
<span class="text-xs text-[var(--text-secondary)]">Rerank results</span>
</label>
</div>
</div>
</div>
</template>
</Dropdown>
</template>
+4 -4
View File
@@ -123,9 +123,9 @@ export const useAgents = async () => {
a.id === id ? agent : a
);
},
async onResponse() {
await refresh();
}
// async onResponse() {
// await refresh();
// }
});
}
@@ -166,7 +166,7 @@ export const useAgents = async () => {
body: topic,
onRequest() {
agents.value = agents.value.map(a =>
a.id === agentId ? { ...a, topics: [{ ...topic, createdAt: new Date() }, ...a.topics] } : a
a.id === agentId ? { ...a, topics: [{ ...topic, createdAt: new Date() }, ...a.topics] } as AgentWithTopics : a
);
},
onResponseError() {
+30 -22
View File
@@ -52,7 +52,6 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
if (!msg) continue;
for (const [partId, content] of parts) {
console.log("content", content);
const part = msg.parts?.find(p => p.id === partId);
if (part) {
part.content += content;
@@ -135,7 +134,6 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
const messageBuffer = textDeltaBuffer.get(payload.messageId)!;
const existing = messageBuffer.get(payload.partId) || '';
messageBuffer.set(payload.partId, existing + payload.content);
console.log("messageBuffer", messageBuffer, existing + payload.content);
scheduleFlush();
break;
@@ -303,8 +301,6 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
baseMessage: BaseMessage,
onRequest?: () => void,
): Promise<Result<void, ChatErrorType>> => {
console.log("sendMessage", baseMessage);
const { user } = useAuth();
if (!user.value) return Err(ChatErrorType.NoUser);
@@ -316,8 +312,6 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
fileIds: baseMessage.fileIds,
}
console.log("sendMessage", message, baseMessage.content, baseMessage.fileIds);
await $fetch(`/api/topic/${unref(topicId)}/message`, {
method: 'POST',
body: {
@@ -355,19 +349,46 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
}
};
const startGeneration = async (model: ModelWithProvider) => {
console.log(model.provider);
const startGeneration = async (model: ModelWithProvider, parentMessageId?: string, agentIdOverride?: string) => {
const providerApiKeyRes = await getProviderAPIKey(model.provider);
if (providerApiKeyRes.ok === false) {
return providerApiKeyRes;
}
const providerApiKey = providerApiKeyRes.data;
const { agents } = await useAgents();
const agent = agents.value.find(agent => agent.id === (agentIdOverride || topic.value?.agentId));
if (!agent) {
return Err(ChatErrorType.NoAgent);
}
const { settings } = await useUserSettings();
let rerank = undefined;
if (agent.config?.search?.rerank && settings.value.systemAssistants.rerank.enabled) {
const rerankModel = await useModels().then(m => m.allModels.value.find(m => m.id === settings.value.systemAssistants.rerank.modelId));
if (rerankModel) {
const rerankProviderApiKeyRes = await getProviderAPIKey(rerankModel.provider);
if (rerankProviderApiKeyRes.ok === false) {
return rerankProviderApiKeyRes;
}
const rerankProviderApiKey = rerankProviderApiKeyRes.data;
rerank = {
modelId: rerankModel.id,
providerApiKey: rerankProviderApiKey,
};
}
}
await $fetch(`/api/topic/${unref(topicId)}/chat`, {
method: 'POST',
body: {
modelId: model.id,
providerApiKey,
rerank,
parentMessageId,
},
});
}
@@ -399,12 +420,6 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
}
let targetMessageIndex = topicMessages.indexOf(targetMessage);
const providerApiKeyRes = await getProviderAPIKey(model.provider);
if (providerApiKeyRes.ok === false) {
return providerApiKeyRes;
}
const providerApiKey = providerApiKeyRes.data;
let parentMessageId = undefined;
let focusedMessages: MessageEntity[] | undefined;
if (targetMessage.role === 'user') {
@@ -429,14 +444,7 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
focusedMessages = topicMessages;
}
await $fetch(`/api/topic/${unref(topicId)}/chat`, {
method: 'POST',
body: {
modelId: model.id,
providerApiKey,
parentMessageId,
},
});
startGeneration(model, parentMessageId);
return Ok(undefined);
}
+1
View File
@@ -6,6 +6,7 @@ export type Provider = typeof schema.providers.$inferSelect;
export type ProviderWithModels = Provider & {
models: Model[];
defaultBaseUrl?: string;
};
export type ModelWithProvider = Model & {