feat: remove search rerank
This commit is contained in:
@@ -46,8 +46,6 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const searchConfig = ref({
|
const searchConfig = ref({
|
||||||
enabled: props.agent?.config?.search?.enabled ?? false,
|
enabled: props.agent?.config?.search?.enabled ?? false,
|
||||||
maxResults: props.agent?.config?.search?.maxResults ?? 10,
|
|
||||||
rerank: props.agent?.config?.search?.rerank ?? false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const toolsConfig = ref({
|
const toolsConfig = ref({
|
||||||
@@ -57,8 +55,6 @@ const toolsConfig = ref({
|
|||||||
watch(() => props.agent?.config?.search, (val) => {
|
watch(() => props.agent?.config?.search, (val) => {
|
||||||
searchConfig.value = {
|
searchConfig.value = {
|
||||||
enabled: val?.enabled ?? false,
|
enabled: val?.enabled ?? false,
|
||||||
maxResults: val?.maxResults ?? 10,
|
|
||||||
rerank: val?.rerank ?? false,
|
|
||||||
};
|
};
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
@@ -79,8 +75,6 @@ const saveAgentConfig = async (partial: {
|
|||||||
...(partial.search !== undefined ? {
|
...(partial.search !== undefined ? {
|
||||||
search: {
|
search: {
|
||||||
enabled: partial.search.enabled,
|
enabled: partial.search.enabled,
|
||||||
maxResults: partial.search.maxResults,
|
|
||||||
rerank: partial.search.rerank,
|
|
||||||
},
|
},
|
||||||
} : {}),
|
} : {}),
|
||||||
...(partial.tools !== undefined ? {
|
...(partial.tools !== undefined ? {
|
||||||
@@ -414,10 +408,7 @@ onUnmounted(() => {
|
|||||||
<ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel"
|
<ModelSelector v-if="providers !== undefined" :add-hotkey="true" v-model="selectedModel"
|
||||||
:providers="providers" />
|
:providers="providers" />
|
||||||
<SearchSelector v-if="selectedModel?.capabilities.includes('tools')" :enabled="searchConfig.enabled"
|
<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:enabled="(v: boolean) => { searchConfig.enabled = v; saveSearchConfig() }"
|
|
||||||
@update:max-results="(v: number) => { searchConfig.maxResults = v; saveSearchConfig() }"
|
|
||||||
@update:rerank="(v: boolean) => { searchConfig.rerank = v; saveSearchConfig() }" />
|
|
||||||
<ToolSelector v-if="selectedModel?.capabilities.includes('tools')" :python="toolsConfig.python"
|
<ToolSelector v-if="selectedModel?.capabilities.includes('tools')" :python="toolsConfig.python"
|
||||||
@update:python="(v: boolean) => { toolsConfig.python = v; saveToolsConfig() }" />
|
@update:python="(v: boolean) => { toolsConfig.python = v; saveToolsConfig() }" />
|
||||||
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" />
|
<FileSelector ref="fileSelectorRef" :selected-model="selectedModel" v-model="files" />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getFaviconUrl,
|
getFaviconUrl,
|
||||||
parseToolValue,
|
parseToolValue,
|
||||||
readSearchSources,
|
readSearchSources,
|
||||||
|
readSearchWarnings,
|
||||||
} from "~/utils/tool-rendering";
|
} from "~/utils/tool-rendering";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -19,7 +20,10 @@ const emit = defineEmits<{
|
|||||||
retry: [];
|
retry: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const sources = computed(() => readSearchSources(parseToolValue(props.toolCall.output)));
|
const output = computed(() => parseToolValue(props.toolCall.output));
|
||||||
|
const sources = computed(() => readSearchSources(output.value));
|
||||||
|
const warnings = computed(() => readSearchWarnings(output.value));
|
||||||
|
const warningSummary = computed(() => warnings.value.map(warning => `${warning.engine}: ${warning.reason}`).join("; "));
|
||||||
|
|
||||||
const query = computed(() => {
|
const query = computed(() => {
|
||||||
const input = parseToolValue(props.toolCall.input);
|
const input = parseToolValue(props.toolCall.input);
|
||||||
@@ -103,6 +107,17 @@ const customTab = computed(() => {
|
|||||||
@retry="emit('retry')"
|
@retry="emit('retry')"
|
||||||
>
|
>
|
||||||
<template #summary>
|
<template #summary>
|
||||||
|
<Tooltip
|
||||||
|
v-if="warnings.length"
|
||||||
|
class="flex items-center text-amber-500 mr-2"
|
||||||
|
:text="`Some search engines failed: ${warningSummary}`"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="i-mynaui-danger-triangle w-4 h-4"
|
||||||
|
role="img"
|
||||||
|
:aria-label="`Some search engines failed: ${warningSummary}`"
|
||||||
|
></span>
|
||||||
|
</Tooltip>
|
||||||
<div v-if="topDomains.length && toolCall.status !== 'pending'" class="flex items-center mr-1" aria-hidden="true">
|
<div v-if="topDomains.length && toolCall.status !== 'pending'" class="flex items-center mr-1" aria-hidden="true">
|
||||||
<span
|
<span
|
||||||
v-for="(domain, idx) in topDomains"
|
v-for="(domain, idx) in topDomains"
|
||||||
@@ -132,7 +147,11 @@ const customTab = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #sources>
|
<template #sources>
|
||||||
<div v-if="sources?.length" class="flex flex-col gap-2 max-h-80 overflow-y-auto scrollbar-thin p-3">
|
<div class="flex flex-col gap-2 max-h-80 overflow-y-auto scrollbar-thin p-3">
|
||||||
|
<p v-if="warnings.length" class="flex items-start gap-2 text-xs text-amber-500 m-0">
|
||||||
|
<span class="i-mynaui-danger-triangle w-4 h-4 shrink-0" aria-hidden="true"></span>
|
||||||
|
<span>Some search engines failed: {{ warningSummary }}</span>
|
||||||
|
</p>
|
||||||
<a
|
<a
|
||||||
v-for="source in sources"
|
v-for="source in sources"
|
||||||
:key="source.url"
|
:key="source.url"
|
||||||
@@ -172,10 +191,10 @@ const customTab = computed(() => {
|
|||||||
{{ source.snippet }}
|
{{ source.snippet }}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<p v-if="!sources?.length" class="text-xs text-[var(--text-tertiary)] m-0">
|
||||||
<p v-else class="text-xs text-[var(--text-tertiary)] p-3 m-0">
|
|
||||||
{{ toolCall.status === "pending" ? "Waiting for results…" : "No usable sources returned." }}
|
{{ toolCall.status === "pending" ? "Waiting for results…" : "No usable sources returned." }}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,39 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DialogType } from '~/composables/useDialog';
|
defineProps<{ enabled: boolean }>();
|
||||||
|
|
||||||
const { openDialog } = await useDialog();
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
enabled: boolean;
|
|
||||||
maxResults: number;
|
|
||||||
rerank: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:enabled': [value: boolean];
|
'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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -46,10 +16,8 @@ const openSettings = () => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #dropdown="{ close }">
|
<template #dropdown>
|
||||||
<div class="flex p-1 gap-4">
|
<div class="flex flex-col gap-1 p-1">
|
||||||
<!-- Left: mode toggle group -->
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<button @click="emit('update:enabled', false)"
|
<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="flex items-start gap-3 rounded-xl px-3 py-2.5 text-left transition-colors duration-150"
|
||||||
:class="!enabled
|
:class="!enabled
|
||||||
@@ -88,53 +56,6 @@ const openSettings = () => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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-solid"></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>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ModelWithProvider } from '~/composables/useModels';
|
import type { ModelWithProvider } from '~/composables/useModels';
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
params?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { providers, allModels } = await useModels();
|
const { providers, allModels } = await useModels();
|
||||||
const { settings, updateSettings } = await useUserSettings();
|
const { settings, updateSettings } = await useUserSettings();
|
||||||
|
|
||||||
const toggle = async (key: string) => {
|
const toggle = async () => {
|
||||||
const current = (settings.value.systemAssistants as any)[key];
|
const current = settings.value.systemAssistants.rename;
|
||||||
await updateSettings({
|
await updateSettings({
|
||||||
systemAssistants: {
|
systemAssistants: {
|
||||||
[key]: {
|
rename: {
|
||||||
...current,
|
...current,
|
||||||
enabled: !current.enabled
|
enabled: !current.enabled
|
||||||
}
|
}
|
||||||
@@ -20,11 +16,11 @@ const toggle = async (key: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateModel = async (key: string, model: ModelWithProvider | undefined | null) => {
|
const updateModel = async (model: ModelWithProvider | undefined | null) => {
|
||||||
const current = (settings.value.systemAssistants as any)[key];
|
const current = settings.value.systemAssistants.rename;
|
||||||
await updateSettings({
|
await updateSettings({
|
||||||
systemAssistants: {
|
systemAssistants: {
|
||||||
[key]: {
|
rename: {
|
||||||
...current,
|
...current,
|
||||||
modelId: model?.id ?? null
|
modelId: model?.id ?? null
|
||||||
}
|
}
|
||||||
@@ -44,15 +40,15 @@ defineEmits(['navigate']);
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-4 py-4 flex-grow overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
class="flex flex-col gap-4 py-4 flex-grow overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||||
<div class="flex flex-col" v-for="(systemAssistant, key) in settings.systemAssistants" :key="key">
|
<div class="flex flex-col">
|
||||||
<label :for="`slider-${key}`" class="flex justify-between gap-4 items-center">
|
<label for="slider-rename" class="flex justify-between gap-4 items-center">
|
||||||
<h4 class="case-capital">{{ key }}</h4>
|
<h4 class="case-capital">Rename</h4>
|
||||||
<Slider :id="`slider-${key}`" :checked="systemAssistant?.enabled ?? false"
|
<Slider id="slider-rename" :checked="settings.systemAssistants.rename?.enabled ?? false"
|
||||||
@click="toggle(String(key))" />
|
@click="toggle" />
|
||||||
</label>
|
</label>
|
||||||
<div class="flex-1 justify-between gap-4 items-center">
|
<div class="flex-1 justify-between gap-4 items-center">
|
||||||
<ModelSelector :providers="providers" :model-value="getModel(systemAssistant?.modelId)"
|
<ModelSelector :providers="providers" :model-value="getModel(settings.systemAssistants.rename?.modelId)"
|
||||||
@update:model-value="(model) => updateModel(String(key), model)" />
|
@update:model-value="updateModel" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
hotkey?: string[];
|
hotkey?: string[];
|
||||||
|
text?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { show, hide } = useTooltip();
|
const { show, hide } = useTooltip();
|
||||||
@@ -14,7 +15,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
const onMouseEnter = () => {
|
const onMouseEnter = () => {
|
||||||
if (triggerRef.value && hasCursor.value) {
|
if (triggerRef.value && hasCursor.value) {
|
||||||
show(triggerRef.value, props.hotkey);
|
show(triggerRef.value, props.hotkey, props.text);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
|
import { useFloating, offset, flip, shift, autoUpdate, hide } from '@floating-ui/vue';
|
||||||
|
|
||||||
const { activeElement, activeHotkey, isVisible } = useTooltip();
|
const { activeElement, activeHotkey, activeText, isVisible } = useTooltip();
|
||||||
|
|
||||||
const tooltipRef = ref<HTMLElement | null>(null);
|
const tooltipRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
@@ -61,7 +61,8 @@ const transformOrigin = computed(() => placement.value.includes('top') ? 'transf
|
|||||||
}
|
}
|
||||||
]" :class="transformOrigin"
|
]" :class="transformOrigin"
|
||||||
class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs">
|
class="pointer-events-none fixed z-35 flex flex-col bg-[var(--bg-surface)] rounded-lg shadow-xl px-1.5 py-1 text-xs">
|
||||||
<div class="flex gap-1 items-center">
|
<span v-if="activeText" class="max-w-xs whitespace-normal">{{ activeText }}</span>
|
||||||
|
<div v-if="displayHotkey?.length" class="flex gap-1 items-center">
|
||||||
<span v-for="key in displayHotkey" :key="key"
|
<span v-for="key in displayHotkey" :key="key"
|
||||||
class="bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
|
class="bg-[var(--bg-container)] px-1 rounded border border-[var(--color-border)]">
|
||||||
<kbd class="font-mono text-[10px] case-capital">{{ key }}</kbd>
|
<kbd class="font-mono text-[10px] case-capital">{{ key }}</kbd>
|
||||||
|
|||||||
@@ -450,31 +450,11 @@ export const useChat = async (topicId: MaybeRef<string>, connect = true) => {
|
|||||||
return Err(ChatErrorType.NoAgent);
|
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`, {
|
await $fetch(`/api/topic/${unref(topicId)}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
modelId: model.id,
|
modelId: model.id,
|
||||||
providerApiKey,
|
providerApiKey,
|
||||||
rerank,
|
|
||||||
parentMessageId,
|
parentMessageId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ const persistTimeout = 150;
|
|||||||
|
|
||||||
export const useTooltip = () => {
|
export const useTooltip = () => {
|
||||||
const activeElement = useState<HTMLElement | null>('tooltip:activeElement', () => null);
|
const activeElement = useState<HTMLElement | null>('tooltip:activeElement', () => null);
|
||||||
|
const activeText = useState<string | null>('tooltip:activeText', () => null);
|
||||||
const activeHotkey = useState<string[] | null>('tooltip:activeHotkey', () => null);
|
const activeHotkey = useState<string[] | null>('tooltip:activeHotkey', () => null);
|
||||||
const isVisible = useState<boolean>('tooltip:isVisible', () => false);
|
const isVisible = useState<boolean>('tooltip:isVisible', () => false);
|
||||||
const dwellTimeoutId = useState<NodeJS.Timeout | null>('tooltip:dwellTimeoutId', () => null);
|
const dwellTimeoutId = useState<NodeJS.Timeout | null>('tooltip:dwellTimeoutId', () => null);
|
||||||
const closeTimeoutId = useState<NodeJS.Timeout | null>('tooltip:closeTimeout', () => null);
|
const closeTimeoutId = useState<NodeJS.Timeout | null>('tooltip:closeTimeout', () => null);
|
||||||
|
|
||||||
const show = (el: HTMLElement, hotkey?: string[]) => {
|
const show = (el: HTMLElement, hotkey?: string[], text?: string) => {
|
||||||
if (closeTimeoutId.value) {
|
if (closeTimeoutId.value) {
|
||||||
clearTimeout(closeTimeoutId.value);
|
clearTimeout(closeTimeoutId.value);
|
||||||
}
|
}
|
||||||
@@ -16,10 +17,12 @@ export const useTooltip = () => {
|
|||||||
if (isVisible.value) {
|
if (isVisible.value) {
|
||||||
activeElement.value = el;
|
activeElement.value = el;
|
||||||
activeHotkey.value = hotkey || null;
|
activeHotkey.value = hotkey || null;
|
||||||
|
activeText.value = text || null;
|
||||||
} else {
|
} else {
|
||||||
dwellTimeoutId.value = setTimeout(() => {
|
dwellTimeoutId.value = setTimeout(() => {
|
||||||
activeElement.value = el;
|
activeElement.value = el;
|
||||||
activeHotkey.value = hotkey || null;
|
activeHotkey.value = hotkey || null;
|
||||||
|
activeText.value = text || null;
|
||||||
isVisible.value = true;
|
isVisible.value = true;
|
||||||
}, dwellTimeout);
|
}, dwellTimeout);
|
||||||
}
|
}
|
||||||
@@ -44,6 +47,7 @@ export const useTooltip = () => {
|
|||||||
return {
|
return {
|
||||||
activeElement: readonly(activeElement),
|
activeElement: readonly(activeElement),
|
||||||
activeHotkey: readonly(activeHotkey),
|
activeHotkey: readonly(activeHotkey),
|
||||||
|
activeText: readonly(activeText),
|
||||||
isVisible: readonly(isVisible),
|
isVisible: readonly(isVisible),
|
||||||
show,
|
show,
|
||||||
hide,
|
hide,
|
||||||
|
|||||||
@@ -27,7 +27,25 @@ export interface SearchSource {
|
|||||||
snippet: string;
|
snippet: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const readSearchWarnings = (value: unknown): { engine: string; reason: string }[] => {
|
||||||
|
if (typeof value !== "object" || value === null || !("warnings" in value) || !Array.isArray(value.warnings)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value.warnings.flatMap((warning: unknown) => {
|
||||||
|
if (typeof warning !== "object" || warning === null
|
||||||
|
|| !("engine" in warning) || typeof warning.engine !== "string"
|
||||||
|
|| !("reason" in warning) || typeof warning.reason !== "string") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ engine: warning.engine, reason: warning.reason }];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const readSearchSources = (value: unknown): SearchSource[] | null => {
|
export const readSearchSources = (value: unknown): SearchSource[] | null => {
|
||||||
|
// Older tool calls stored the results array directly.
|
||||||
|
if (typeof value === "object" && value !== null && "results" in value) {
|
||||||
|
value = value.results;
|
||||||
|
}
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ export const settings = pgTable('settings', {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
modelId: string | null;
|
modelId: string | null;
|
||||||
};
|
};
|
||||||
rerank: {
|
|
||||||
enabled: boolean;
|
|
||||||
modelId: string | null;
|
|
||||||
};
|
|
||||||
}>().notNull().default({} as any),
|
}>().notNull().default({} as any),
|
||||||
appearance: jsonb('appearance').$type<{
|
appearance: jsonb('appearance').$type<{
|
||||||
colorScheme?: 'light' | 'dark' | 'system';
|
colorScheme?: 'light' | 'dark' | 'system';
|
||||||
@@ -110,8 +106,6 @@ export const agents = pgTable('agents', {
|
|||||||
config: jsonb('config').$type<{
|
config: jsonb('config').$type<{
|
||||||
search?: {
|
search?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
rerank: boolean;
|
|
||||||
maxResults: number;
|
|
||||||
};
|
};
|
||||||
tools?: {
|
tools?: {
|
||||||
python?: boolean;
|
python?: boolean;
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
config: z.object({
|
config: z.object({
|
||||||
search: z.object({
|
search: z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
rerank: z.boolean(),
|
|
||||||
maxResults: z.number().min(1).max(50),
|
|
||||||
}).optional(),
|
}).optional(),
|
||||||
tools: z.object({
|
tools: z.object({
|
||||||
python: z.boolean().optional(),
|
python: z.boolean().optional(),
|
||||||
|
|||||||
@@ -22,21 +22,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
rename: {
|
rename: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
modelId: null,
|
modelId: null,
|
||||||
},
|
|
||||||
rerank: {
|
|
||||||
enabled: false,
|
|
||||||
modelId: null,
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const missingSystemAssistants = Object.keys(defaultSystemAssistants).filter(key => !(key in existing!.systemAssistants));
|
if (!("rename" in existing.systemAssistants) || "rerank" in existing.systemAssistants) {
|
||||||
if (missingSystemAssistants.length > 0) {
|
|
||||||
const [updated] = await db.update(settings)
|
const [updated] = await db.update(settings)
|
||||||
.set({
|
.set({
|
||||||
systemAssistants: {
|
systemAssistants: {
|
||||||
...defaultSystemAssistants,
|
...defaultSystemAssistants,
|
||||||
...existing.systemAssistants,
|
rename: existing.systemAssistants.rename ?? defaultSystemAssistants.rename,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.where(eq(settings.id, existing.id)).returning();
|
.where(eq(settings.id, existing.id)).returning();
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ const appearanceSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateSettingsSchema = z.object({
|
const updateSettingsSchema = z.object({
|
||||||
systemAssistants: z.any().optional(),
|
systemAssistants: z.object({
|
||||||
|
rename: z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
modelId: z.string().nullable(),
|
||||||
|
}).partial().optional(),
|
||||||
|
}).optional(),
|
||||||
appearance: appearanceSchema.optional(),
|
appearance: appearanceSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,28 +58,22 @@ export default defineEventHandler(async (event) => {
|
|||||||
return { ...base, ...incoming };
|
return { ...base, ...incoming };
|
||||||
};
|
};
|
||||||
|
|
||||||
const mergeSystemAssistants = (
|
const newSystemAssistants = {
|
||||||
existingSA: Record<string, unknown> | null,
|
rename: {
|
||||||
incoming: typeof updates.systemAssistants,
|
enabled: updates.systemAssistants?.rename?.enabled ?? existing.systemAssistants.rename?.enabled ?? false,
|
||||||
): Record<string, unknown> => {
|
modelId: updates.systemAssistants?.rename?.modelId !== undefined
|
||||||
const base = existingSA || {};
|
? updates.systemAssistants.rename.modelId
|
||||||
if (!incoming) return base;
|
: existing.systemAssistants.rename?.modelId ?? null,
|
||||||
return { ...base, ...incoming };
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const newAppearance = mergeAppearance(
|
const newAppearance = mergeAppearance(
|
||||||
existing.appearance as Record<string, unknown> | null,
|
existing.appearance as Record<string, unknown> | null,
|
||||||
updates.appearance,
|
updates.appearance,
|
||||||
);
|
);
|
||||||
const newSystemAssistants = mergeSystemAssistants(
|
|
||||||
existing.systemAssistants as Record<string, unknown> | null,
|
|
||||||
updates.systemAssistants,
|
|
||||||
);
|
|
||||||
|
|
||||||
await db.update(settings)
|
await db.update(settings)
|
||||||
.set({
|
.set({
|
||||||
appearance: newAppearance,
|
appearance: newAppearance,
|
||||||
// @ts-ignore - I dont have the energy to type this correctly right now
|
|
||||||
systemAssistants: newSystemAssistants,
|
systemAssistants: newSystemAssistants,
|
||||||
})
|
})
|
||||||
.where(and(eq(settings.id, existingId)));
|
.where(and(eq(settings.id, existingId)));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { db } from "~~/server/lib/db";
|
|||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { type MessageEntity } from '~/composables/useChat';
|
import { type MessageEntity } from '~/composables/useChat';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { isLoopFinished, type ModelMessage, rerank, type RerankingModel, streamText, type StreamTextTransform, type Tool, tool } from "ai";
|
import { isLoopFinished, type ModelMessage, streamText, type StreamTextTransform, type Tool, tool } from "ai";
|
||||||
import { generations, messageParts, messages, toolCalls, ToolCallType } from "~~/drizzle/schema";
|
import { generations, messageParts, messages, toolCalls, ToolCallType } from "~~/drizzle/schema";
|
||||||
import { topicEvents } from "~~/server/utils/events";
|
import { topicEvents } from "~~/server/utils/events";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -11,10 +11,10 @@ import { type Model } from "~/composables/useModels";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { buildFocusedMessageTree, buildMessageTree, marshallMessages } from "~~/utils/message";
|
import { buildFocusedMessageTree, buildMessageTree, marshallMessages } from "~~/utils/message";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { isRerankingProvider } from "~~/server/utils/ai-provider";
|
|
||||||
import { generateFileToken } from "~~/server/utils/file-token";
|
import { generateFileToken } from "~~/server/utils/file-token";
|
||||||
import { serializeToolError } from "~~/utils/tool-errors";
|
import { serializeToolError } from "~~/utils/tool-errors";
|
||||||
import { readFirecrawlResponse } from "~~/server/utils/firecrawl";
|
import { readFirecrawlResponse } from "~~/server/utils/firecrawl";
|
||||||
|
import { limitSearchResults, readSearchResponse, searchInputSchema, searchOutputSchema } from "~~/server/utils/search";
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
await protectRoute(event);
|
await protectRoute(event);
|
||||||
@@ -25,10 +25,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
const result = await readValidatedBody(event, z.object({
|
const result = await readValidatedBody(event, z.object({
|
||||||
parentMessageId: z.string().optional(),
|
parentMessageId: z.string().optional(),
|
||||||
modelId: z.string(),
|
modelId: z.string(),
|
||||||
rerank: z.object({
|
|
||||||
modelId: z.string(),
|
|
||||||
providerApiKey: z.string().optional(),
|
|
||||||
}).optional(),
|
|
||||||
args: z.record(z.string(), z.any()).optional(),
|
args: z.record(z.string(), z.any()).optional(),
|
||||||
providerApiKey: z.string().optional(),
|
providerApiKey: z.string().optional(),
|
||||||
}).safeParse);
|
}).safeParse);
|
||||||
@@ -40,7 +36,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { parentMessageId, modelId, rerank: rerankConfig, providerApiKey, args } = result.data;
|
const { parentMessageId, modelId, providerApiKey, args } = result.data;
|
||||||
|
|
||||||
const topic = await db.query.topics.findFirst({
|
const topic = await db.query.topics.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -116,90 +112,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentSearchConfig = topic.agent.config?.search;
|
const searchEnabled = topic.agent.config?.search?.enabled === true;
|
||||||
let searchParam: false | { config: SearchTheWebConfig } = false;
|
|
||||||
|
|
||||||
if (agentSearchConfig?.enabled) {
|
|
||||||
searchParam = { config: { rerank: false, maxResults: agentSearchConfig.maxResults ?? 10 } };
|
|
||||||
|
|
||||||
if (agentSearchConfig.rerank && rerankConfig) {
|
|
||||||
const rerankModel = await db.query.models.findFirst({
|
|
||||||
where: {
|
|
||||||
id: rerankConfig.modelId,
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
with: {
|
|
||||||
provider: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rerankModel === undefined) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Invalid rerank model',
|
|
||||||
data: {
|
|
||||||
code: 'INVALID_RERANK_MODEL',
|
|
||||||
ok: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const rerankProviderDetails = await getProviderDetails(rerankModel.provider, rerankConfig.providerApiKey, rerankModel);
|
|
||||||
if (!rerankProviderDetails.ok) {
|
|
||||||
switch (rerankProviderDetails.error) {
|
|
||||||
case GatewayFetchError.NoProviderApiKey: {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: `${rerankModel.provider.type} provider requires an API key`,
|
|
||||||
data: {
|
|
||||||
code: 'NO_RERANK_PROVIDER_API_KEY',
|
|
||||||
ok: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
case GatewayFetchError.NoProviderBaseUrl: {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Invalid provider URL',
|
|
||||||
data: {
|
|
||||||
code: 'BAD_RERANK_PROVIDER_URL',
|
|
||||||
ok: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { gateway: rerankGateway } = rerankProviderDetails.data;
|
|
||||||
if (rerankGateway === null) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'Invalid gateway',
|
|
||||||
data: {
|
|
||||||
code: 'INVALID_RERANK_GATEWAY',
|
|
||||||
ok: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRerankingProvider(rerankGateway.gateway)) {
|
|
||||||
searchParam.config = {
|
|
||||||
rerank: true,
|
|
||||||
maxResults: agentSearchConfig.maxResults ?? 10,
|
|
||||||
model: rerankGateway.gateway.reranking(rerankModel.externalId)
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 500,
|
|
||||||
statusMessage: 'Invalid rerank model',
|
|
||||||
data: {
|
|
||||||
code: 'INVALID_RERANK_MODEL',
|
|
||||||
ok: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let agentmessage = await db.transaction(async tx => {
|
let agentmessage = await db.transaction(async tx => {
|
||||||
const generationId = nanoid();
|
const generationId = nanoid();
|
||||||
@@ -332,7 +245,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
generateResponse(
|
generateResponse(
|
||||||
agentmessage as MessageEntity,
|
agentmessage as MessageEntity,
|
||||||
{ gateway: gateway.gateway, model, parameters: args },
|
{ gateway: gateway.gateway, model, parameters: args },
|
||||||
searchParam,
|
searchEnabled,
|
||||||
{
|
{
|
||||||
python: topic.agent.config?.tools?.python ?? false,
|
python: topic.agent.config?.tools?.python ?? false,
|
||||||
},
|
},
|
||||||
@@ -418,61 +331,23 @@ const evalPython = async (code: string): Promise<string> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SearchTheWebRerankedConfig {
|
export const searchTheWeb = async (query: string, results: number) => {
|
||||||
rerank: true;
|
|
||||||
maxResults: number;
|
|
||||||
model: RerankingModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchTheWebUnrankedConfig {
|
|
||||||
rerank?: false;
|
|
||||||
maxResults: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SearchTheWebConfig = SearchTheWebRerankedConfig | SearchTheWebUnrankedConfig;
|
|
||||||
|
|
||||||
export const searchTheWeb = (config: SearchTheWebConfig) => {
|
|
||||||
return async (query: string) => {
|
|
||||||
const searchUrl = new URL(process.env.SEARCH_API_URL ?? process.env.SEARXNG_URL ?? "");
|
const searchUrl = new URL(process.env.SEARCH_API_URL ?? process.env.SEARXNG_URL ?? "");
|
||||||
searchUrl.pathname = `${searchUrl.pathname.replace(/\/+$/, "")}/search`;
|
searchUrl.pathname = `${searchUrl.pathname.replace(/\/+$/, "")}/search`;
|
||||||
|
|
||||||
const results = await $fetch<{
|
const response = await $fetch<unknown>(searchUrl.toString(), {
|
||||||
results: Array<{
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
content: string;
|
|
||||||
}>;
|
|
||||||
}>(searchUrl.toString(), {
|
|
||||||
query: {
|
query: {
|
||||||
q: query,
|
q: query,
|
||||||
format: 'json',
|
format: 'json',
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const sites = results.results.map((item: any) => ({
|
const result = readSearchResponse(response);
|
||||||
title: item.title,
|
if (!result.ok) {
|
||||||
link: item.url,
|
// Reject execution so the SDK emits a tool-error rather than an empty success.
|
||||||
snippet: item.content,
|
return Promise.reject(new Error(result.error));
|
||||||
}));
|
|
||||||
|
|
||||||
if (config.rerank) {
|
|
||||||
const { ranking } = await rerank({
|
|
||||||
model: config.model,
|
|
||||||
query,
|
|
||||||
documents: sites.map(site => site.snippet),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ranked_sites = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < ranking.length; i++) {
|
|
||||||
ranked_sites.push(sites[ranking[i]!.originalIndex]);
|
|
||||||
}
|
}
|
||||||
|
return limitSearchResults(result.data, results);
|
||||||
return ranked_sites.slice(0, config.maxResults || 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sites.slice(0, config.maxResults || 10);
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchUrlTool = tool({
|
const fetchUrlTool = tool({
|
||||||
@@ -529,9 +404,7 @@ async function generateResponse(
|
|||||||
model: Model,
|
model: Model,
|
||||||
parameters?: Record<string, any>,
|
parameters?: Record<string, any>,
|
||||||
},
|
},
|
||||||
search: false | {
|
search: boolean,
|
||||||
config: SearchTheWebConfig,
|
|
||||||
},
|
|
||||||
enabledTools: {
|
enabledTools: {
|
||||||
python?: boolean;
|
python?: boolean;
|
||||||
},
|
},
|
||||||
@@ -560,20 +433,11 @@ async function generateResponse(
|
|||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
tools.search = tool({
|
tools.search = tool({
|
||||||
description: 'Searches the web',
|
description: 'Searches the web. Choose how many results to return on every call using the required results argument.',
|
||||||
inputSchema: z.object({
|
inputSchema: searchInputSchema,
|
||||||
query: z.string(),
|
outputSchema: searchOutputSchema,
|
||||||
}),
|
execute: async ({ query, results }) => {
|
||||||
outputSchema: z.array(
|
return await searchTheWeb(query, results);
|
||||||
z.object({
|
|
||||||
title: z.string(),
|
|
||||||
link: z.string(),
|
|
||||||
snippet: z.string(),
|
|
||||||
engine: z.string(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
execute: async ({ query }) => {
|
|
||||||
return await searchTheWeb(search.config)(query);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
tools.fetchUrl = fetchUrlTool;
|
tools.fetchUrl = fetchUrlTool;
|
||||||
|
|||||||
@@ -67,11 +67,3 @@ export async function getProviderDetails(provider: ProviderDrizzle, providerApiK
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRerankingProvider(gateway: any): gateway is { reranking: (modelId: string) => any } {
|
|
||||||
return (
|
|
||||||
gateway !== null &&
|
|
||||||
'reranking' in gateway &&
|
|
||||||
typeof (gateway as any).reranking === 'function'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Result } from "../../types/result";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Err, Ok } from "../../types/result";
|
||||||
|
|
||||||
|
const searchResponseSchema = z.object({
|
||||||
|
results: z.array(z.object({
|
||||||
|
title: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
content: z.string().optional().default(""),
|
||||||
|
})),
|
||||||
|
unresponsive_engines: z.array(z.tuple([z.string(), z.string()])).optional().default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchInputSchema = z.object({
|
||||||
|
query: z.string(),
|
||||||
|
results: z.number().int().positive().describe("Number of search results to return; choose this for every search."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const searchOutputSchema = z.object({
|
||||||
|
results: z.array(z.object({
|
||||||
|
title: z.string(),
|
||||||
|
link: z.string(),
|
||||||
|
snippet: z.string(),
|
||||||
|
})),
|
||||||
|
warnings: z.array(z.object({ engine: z.string(), reason: z.string() })),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SearchOutput = z.infer<typeof searchOutputSchema>;
|
||||||
|
|
||||||
|
export const limitSearchResults = (output: SearchOutput, results: number): SearchOutput => ({
|
||||||
|
results: output.results.slice(0, results),
|
||||||
|
warnings: output.warnings,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const readSearchResponse = (body: unknown): Result<SearchOutput, string> => {
|
||||||
|
const parsed = searchResponseSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Err("Search returned an unexpected response format.");
|
||||||
|
}
|
||||||
|
const warnings = parsed.data.unresponsive_engines.map(([engine, reason]) => ({ engine, reason }));
|
||||||
|
if (parsed.data.results.length === 0 && warnings.length > 0) {
|
||||||
|
return Err(`Search returned no results because engines failed: ${warnings.map(warning => `${warning.engine}: ${warning.reason}`).join("; ")}`);
|
||||||
|
}
|
||||||
|
return Ok({
|
||||||
|
results: parsed.data.results.map(item => ({
|
||||||
|
title: item.title,
|
||||||
|
link: item.url,
|
||||||
|
snippet: item.content,
|
||||||
|
})),
|
||||||
|
warnings,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { limitSearchResults, readSearchResponse, searchInputSchema, searchOutputSchema } from "../server/utils/search";
|
||||||
|
import { readSearchSources, readSearchWarnings } from "../app/utils/tool-rendering";
|
||||||
|
|
||||||
|
const site = { title: "Example", url: "https://example.com", content: "Example content" };
|
||||||
|
|
||||||
|
describe("search invocation count", () => {
|
||||||
|
test("requires a positive integer on every invocation", () => {
|
||||||
|
expect(searchInputSchema.safeParse({ query: "example" }).success).toBe(false);
|
||||||
|
for (const results of [0, -1, 1.5, "2"]) {
|
||||||
|
expect(searchInputSchema.safeParse({ query: "example", results }).success).toBe(false);
|
||||||
|
}
|
||||||
|
expect(searchInputSchema.safeParse({ query: "example", results: 2 }).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("slices ranked search results without dropping warnings", () => {
|
||||||
|
const output = {
|
||||||
|
results: [
|
||||||
|
{ title: "First", link: "https://first.example", snippet: "" },
|
||||||
|
{ title: "Second", link: "https://second.example", snippet: "" },
|
||||||
|
],
|
||||||
|
warnings: [{ engine: "google", reason: "CAPTCHA" }],
|
||||||
|
};
|
||||||
|
expect(limitSearchResults(output, 1)).toEqual({
|
||||||
|
results: [output.results[0]],
|
||||||
|
warnings: output.warnings,
|
||||||
|
});
|
||||||
|
expect(limitSearchResults(output, 3)).toEqual(output);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("search engine failures", () => {
|
||||||
|
test("returns an error when empty results accompany engine failures", () => {
|
||||||
|
const result = readSearchResponse({
|
||||||
|
results: [],
|
||||||
|
unresponsive_engines: [["google", "Too many requests"], ["bing", "Suspended: Too many requests"]],
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toContain("google: Too many requests");
|
||||||
|
expect(result.error).toContain("bing: Suspended: Too many requests");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps partial results successful with renderable warnings", () => {
|
||||||
|
const result = readSearchResponse({ results: [site], unresponsive_engines: [["google", "CAPTCHA"]] });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(searchOutputSchema.safeParse(result.data).success).toBe(true);
|
||||||
|
expect(readSearchSources(result.data)?.[0]?.url).toBe(site.url + "/");
|
||||||
|
expect(readSearchWarnings(result.data)).toEqual([{ engine: "google", reason: "CAPTCHA" }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts genuinely empty results without engine failures", () => {
|
||||||
|
const result = readSearchResponse({ results: [] });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data).toEqual({ results: [], warnings: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts results without snippets or failures", () => {
|
||||||
|
const result = readSearchResponse({ results: [{ title: site.title, url: site.url }] });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.data.results[0]?.snippet).toBe("");
|
||||||
|
expect(result.data.warnings).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects malformed responses", () => {
|
||||||
|
expect(readSearchResponse({ error: "Invalid query" }).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still renders legacy arrays and ignores malformed warnings", () => {
|
||||||
|
const legacy = [{ title: site.title, link: site.url, snippet: site.content }];
|
||||||
|
expect(readSearchSources(legacy)).toEqual(readSearchSources({ results: legacy }));
|
||||||
|
expect(readSearchWarnings(legacy)).toEqual([]);
|
||||||
|
expect(readSearchWarnings({ warnings: [null, {}, { engine: "google", reason: 429 }] })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user