Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 89 additions & 62 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,16 @@ reqwest = { version = "0.12", default-features = false, features = [
"multipart",
] }
tower-http = { version = "0.5", features = ["cors"] }
omniference = "0.2"
omniference = { git = "https://github.com/Henrik-3/omniference.git", rev = "fd69edd438430d68f9b81b5570033b12d33a4fd2" }
aes-gcm = "0.10"
rand = "0.8"
rust_decimal = { version = "1.39.0", features = ["db-postgres", "serde"] }
futures-util = "0.3.31"
async-stream = "0.3.6"
tokio-util = "0.7.17"
extism = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sha2 = "0.10"
base64 = "0.22"
thiserror = "2"
Expand Down
2 changes: 2 additions & 0 deletions frontend/components/AppSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const store = useMainStore();
const chatStore = useChatStore();
const budgetStore = useBudgetStore();
const router = useRouter();
const route = useRoute();

const showWorkspaceManager = ref(false);

Expand All @@ -122,6 +123,7 @@ const userInitials = computed(() => {
});

function goToSettings() {
store.settingsReturnPath = route.path.startsWith('/chats/') ? route.fullPath : '/';
router.push('/settings');
}

Expand Down
109 changes: 102 additions & 7 deletions frontend/components/chat/MessageItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

<div class="flex flex-1 flex-col gap-2" :class="isUser ? 'items-end' : 'items-start'">
<div
v-if="!isUser && message.reasoning_content"
v-if="!isUser && !hasInterleavedParts && message.reasoning_content"
class="flex cursor-pointer items-center gap-2 text-primary transition-opacity hover:opacity-80 select-none"
@click="showReasoning = !showReasoning"
>
Expand All @@ -21,7 +21,7 @@
</div>

<Transition name="expand">
<div v-if="showReasoning && (message.reasoning_content || isStreamingReasoning)" class="w-full max-w-3xl rounded-xl bg-muted/50 border p-4">
<div v-if="!hasInterleavedParts && showReasoning && (message.reasoning_content || isStreamingReasoning)" class="w-full max-w-3xl rounded-xl bg-muted/50 border p-4">
<div class="prose prose-sm dark:prose-invert max-w-none opacity-80" v-html="renderedReasoning" @click="handleCodeBlockClick" />
<div v-if="isStreamingReasoning && !message.reasoning_content" class="flex items-center gap-1 py-1">
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-primary/50" />
Expand All @@ -31,7 +31,53 @@
</div>
</Transition>

<div v-if="!isUser && message.tool_calls && message.tool_calls.length > 0" class="flex flex-col gap-2 mt-2 w-full max-w-3xl">
<div v-if="!isUser && hasInterleavedParts" class="flex flex-col gap-2 mt-2 w-full max-w-3xl">
<template v-for="(part, idx) in orderedParts" :key="idx">
<div v-if="part.type === 'reasoning'" class="w-full max-w-3xl">
<div
class="flex cursor-pointer items-center gap-2 text-primary transition-opacity hover:opacity-80 select-none"
@click="toggleReasoning(idx)"
>
<Brain class="h-3.5 w-3.5 fill-current" />
<span class="text-[10px] font-bold uppercase tracking-widest">{{ store.getTranslation('chat.message_item.reasoning') }}</span>
<ChevronDown class="h-3 w-3 transition-transform" :class="isReasoningOpen(idx) ? 'rotate-180' : ''" />
</div>
<Transition name="expand">
<div v-if="isReasoningOpen(idx)" class="mt-2 rounded-xl bg-muted/50 border p-4">
<div class="prose prose-sm dark:prose-invert max-w-none opacity-80" v-html="part.html" @click="handleCodeBlockClick" />
</div>
</Transition>
</div>
<div
v-else-if="part.type === 'text'"
class="prose prose-sm md:prose-base dark:prose-invert max-w-3xl"
v-html="part.html"
@click="handleCodeBlockClick"
/>
<ToolExecutionDisplay
v-else-if="part.type === 'tool_call' && part.tool"
:id="part.tool.tool_call_id"
:name="part.tool.tool_name"
:args="part.tool.input_args"
:output="part.tool.output"
:error="part.tool.error || undefined"
:is-executing="!part.tool.output && !part.tool.error"
/>
<div
v-else-if="part.type === 'image'"
class="w-fit max-w-full overflow-hidden rounded-lg border border-border cursor-pointer hover:opacity-90 transition-opacity"
@click="openImagePreview(part.url, `${part.imageId}.png`)"
>
<img
:src="part.url"
class="max-h-96 max-w-full object-contain"
:alt="store.getTranslation('chat.tool_execution.generated_image')"
/>
</div>
</template>
</div>

<div v-else-if="!isUser && message.tool_calls && message.tool_calls.length > 0" class="flex flex-col gap-2 mt-2 w-full max-w-3xl">
<ToolExecutionDisplay
v-for="tool in message.tool_calls"
:key="tool.tool_call_id"
Expand Down Expand Up @@ -148,13 +194,13 @@

<!-- Assistant message content -->
<div
v-else-if="!isUser && (message.content || !isStreaming)"
v-else-if="!isUser && !hasInterleavedParts && (message.content || !isStreaming)"
class="prose prose-sm md:prose-base dark:prose-invert max-w-3xl"
v-html="renderedContent"
@click="handleCodeBlockClick"
/>

<div v-else-if="!isUser && isStreaming && !message.content" class="flex items-center gap-1 py-2">
<div v-else-if="!isUser && !hasInterleavedParts && isStreaming && !message.content" class="flex items-center gap-1 py-2">
<span class="h-2 w-2 animate-bounce rounded-full bg-muted-foreground/50" />
<span class="h-2 w-2 animate-bounce rounded-full bg-muted-foreground/50" style="animation-delay: 0.15s" />
<span class="h-2 w-2 animate-bounce rounded-full bg-muted-foreground/50" style="animation-delay: 0.3s" />
Expand Down Expand Up @@ -224,8 +270,8 @@ const attachedImages = computed(() => {
return [];
}
return props.message.content_parts
.filter((part: any) => part.type === 'image' && part.image_id)
.map((part: any) => ({
.filter(part => part.type === 'image')
.map(part => ({
image_id: part.image_id,
url: `/api/v1/images/${part.image_id}`,
}));
Expand Down Expand Up @@ -281,6 +327,55 @@ const renderedContent = computed(() => {
return renderComplete(props.message.content);
});

const hasInterleavedParts = computed(() => {
if (isUser.value) return false;
const parts = props.message.content_parts;
return Array.isArray(parts) && parts.some(p => p.type === 'tool_call');
});

const partHtmlCache = new Map<number, {text: string; streaming: boolean; html: string}>();

const orderedParts = computed(() => {
const parts = props.message.content_parts;
if (!Array.isArray(parts)) return [];
return parts.filter(part => part.type !== 'tool_result').map((part, index) => {
if (part.type === 'tool_call') {
const tool = props.message.tool_calls?.find(tc => tc.tool_call_id === part.id);
return {type: 'tool_call' as const, tool};
}
if (part.type === 'image') {
return {
type: 'image' as const,
imageId: part.image_id,
url: `/api/v1/images/${encodeURIComponent(part.image_id)}`,
};
}
if (part.type === 'reasoning') {
const text = part.text;
const cached = partHtmlCache.get(index);
const html = cached?.text === text && cached.streaming === isStreaming.value ? cached.html : isStreaming.value ? renderStreaming(text) : renderComplete(text);
partHtmlCache.set(index, {text, streaming: isStreaming.value, html});
return {type: 'reasoning' as const, html};
}
const text = part.text;
const cached = partHtmlCache.get(index);
const html = cached?.text === text && cached.streaming === isStreaming.value ? cached.html : isStreaming.value ? renderStreaming(text) : renderComplete(text);
partHtmlCache.set(index, {text, streaming: isStreaming.value, html});
return {type: 'text' as const, html};
});
});

const reasoningToggles = ref<Record<number, boolean>>({});

function isReasoningOpen(idx: number): boolean {
if (idx in reasoningToggles.value) return reasoningToggles.value[idx];
return isStreaming.value && idx === orderedParts.value.length - 1;
}

function toggleReasoning(idx: number) {
reasoningToggles.value[idx] = !isReasoningOpen(idx);
}

const renderedReasoning = computed(() => {
if (!props.message.reasoning_content) return '';

Expand Down
3 changes: 3 additions & 0 deletions frontend/components/chat/ToolExecutionDisplay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ function isValidImageProtocol(url: string): boolean {
const imageUrl = computed(() => {
if (!props.output) return null;
const out = typeof props.output === 'string' ? tryParseJson(props.output) : props.output;
if (out?.image_id && typeof out.image_id === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(out.image_id)) {
return `/api/v1/images/${encodeURIComponent(out.image_id)}`;
}
if (out?.image_url && isValidImageProtocol(out.image_url)) return out.image_url;
if (out?.image_reference && isValidImageProtocol(out.image_reference)) return out.image_reference;
if (out?.url && isImageUrl(out.url)) return out.url;
Expand Down
54 changes: 43 additions & 11 deletions frontend/components/settings/DefaultModelPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<PopoverTrigger as-child>
<button
:disabled="disabled"
class="flex items-center gap-2 px-3 py-2 rounded-md border border-input bg-background hover:bg-accent transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed w-full max-w-sm"
class="flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<template v-if="selectedModel">
<img v-if="selectedModel.icon" :src="selectedModel.icon" class="h-4 w-4 rounded object-cover shrink-0" />
Expand Down Expand Up @@ -59,7 +59,7 @@
v-for="model in pickerModels"
:key="model.id"
class="px-3 py-2 hover:bg-accent/50 cursor-pointer transition-colors border-b border-border/50 last:border-0"
:class="model.model_id === modelValue ? 'bg-accent/30' : ''"
:class="modelValueFor(model) === modelValue ? 'bg-accent/30' : ''"
@click="selectModel(model)"
>
<div class="flex items-start gap-3">
Expand All @@ -81,7 +81,7 @@
<p class="text-xs text-muted-foreground mt-0.5 truncate">{{ model.model_id }}</p>
</div>

<Check v-if="model.model_id === modelValue" class="h-4 w-4 shrink-0 text-primary mt-0.5" />
<Check v-if="modelValueFor(model) === modelValue" class="h-4 w-4 shrink-0 text-primary mt-0.5" />
</div>
</div>

Expand Down Expand Up @@ -131,17 +131,23 @@ import {Bot, ChevronDown, Check, Loader2, Search, X} from 'lucide-vue-next';
import {useMainStore} from '~/stores';
import {useModelPicker} from '~/composables/useModelPicker';
import {Popover, PopoverContent, PopoverTrigger} from '~/components/ui/popover';
import type {ModelList} from '~/types/chat';
import type {ModelList, PaginatedResponse} from '~/types/chat';

const props = withDefaults(defineProps<{
modelValue: string | null;
endpoint?: string;
providerEndpoint?: string;
selectedModelEndpoint?: string;
disabled?: boolean;
placeholder?: string;
valueMode?: 'model_id' | 'uuid';
}>(), {
endpoint: '/api/v1/models',
providerEndpoint: undefined,
selectedModelEndpoint: undefined,
disabled: false,
placeholder: undefined,
valueMode: 'model_id',
});

const emit = defineEmits<{
Expand All @@ -166,20 +172,42 @@ const {
onSearchInput: pickerOnSearch,
selectFilter: pickerSelectFilter,
init: pickerInit,
} = useModelPicker({endpoint: props.endpoint});
} = useModelPicker({endpoint: props.endpoint, providerEndpoint: props.providerEndpoint});

const scrollContainer = ref<HTMLElement | null>(null);
const sentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;

const selectedModel = computed<ModelList | null>(() => {
if (!props.modelValue) return null;
return pickerModels.value.find(m => m.model_id === props.modelValue) ?? null;
});
const selectedModel = ref<ModelList | null>(null);

watch(
() => props.modelValue,
async value => {
if (!value) {
selectedModel.value = null;
return;
}
const loaded = pickerModels.value.find(model => modelValueFor(model) === value);
if (loaded) {
selectedModel.value = loaded;
return;
}
try {
const {$customFetch} = useNuxtApp();
const model = props.valueMode === 'uuid' && props.selectedModelEndpoint
? await $customFetch<ModelList>(`${props.selectedModelEndpoint}/${value}`)
: (await $customFetch<PaginatedResponse<ModelList>>(props.endpoint, {params: {query: value, size: '50'}})).items.find(item => modelValueFor(item) === value) ?? null;
if (props.modelValue === value) selectedModel.value = model;
} catch {
if (props.modelValue === value) selectedModel.value = null;
}
},
{immediate: true},
);

watch(isOpen, async open => {
if (open) {
await pickerInit();
if (props.modelValue) selectedModel.value = pickerModels.value.find(model => modelValueFor(model) === props.modelValue) ?? selectedModel.value;
setupObserver();
} else {
teardownObserver();
Expand Down Expand Up @@ -210,7 +238,11 @@ function teardownObserver() {
onUnmounted(teardownObserver);

function selectModel(model: ModelList | null) {
emit('update:modelValue', model?.model_id ?? null);
emit('update:modelValue', model ? modelValueFor(model) : null);
isOpen.value = false;
}

function modelValueFor(model: ModelList) {
return props.valueMode === 'uuid' ? model.id : model.model_id;
}
</script>
5 changes: 3 additions & 2 deletions frontend/composables/useModelPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type {ModelList, PaginatedResponse, ProviderTab} from '~/types/chat';

export type ModelFilter = 'all' | 'favorites' | string;

export function useModelPicker(options: {endpoint?: string} = {}) {
export function useModelPicker(options: {endpoint?: string; providerEndpoint?: string} = {}) {
const {$customFetch} = useNuxtApp();
const endpoint = options.endpoint ?? '/api/v1/models';
const providerEndpoint = options.providerEndpoint ?? '/api/v1/providers';

const models = ref<ModelList[]>([]);
const providers = ref<ProviderTab[]>([]);
Expand Down Expand Up @@ -38,7 +39,7 @@ export function useModelPicker(options: {endpoint?: string} = {}) {

async function loadProviders() {
try {
const res = await $customFetch<ProviderTab[]>('/api/v1/providers');
const res = await $customFetch<ProviderTab[]>(providerEndpoint);
providers.value = res ?? [];
} catch (e) {
console.error('Failed to fetch model providers:', e);
Expand Down
4 changes: 3 additions & 1 deletion frontend/pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ const activeTab = computed(() => {
});

function goBack() {
router.push('/');
const returnPath = store.settingsReturnPath;
store.settingsReturnPath = '/';
router.push(returnPath === '/' || returnPath.startsWith('/chats/') ? returnPath : '/');
}

onMounted(() => {
Expand Down
Loading