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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type Provider = typeof schema.providers.$inferSelect;
|
||||
|
||||
export type ProviderWithModels = Provider & {
|
||||
models: Model[];
|
||||
defaultBaseUrl?: string;
|
||||
};
|
||||
|
||||
export type ModelWithProvider = Model & {
|
||||
|
||||
Reference in New Issue
Block a user