openpencil/src/components/chat/ProviderSettings.vue
Anton A S 82f586ac73 Fix AI chat not reacting to provider/model changes
- Mark transport dirty instead of resetting chat on settings change
- Rebuild Chat with new transport while preserving message history
- Watch apiKey and customBaseURL changes in addition to model/provider
- Fix provider select z-index inside settings popover (z-52 + isolate)
- Prevent popover dismiss when interacting with select dropdown
2026-03-13 10:17:19 +03:00

215 lines
6.6 KiB
Vue

<script setup lang="ts">
import {
PopoverClose,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger,
TabsContent,
TabsList,
TabsRoot,
TabsTrigger
} from 'reka-ui'
import { ref, watch } from 'vue'
import ProviderSelect from '@/components/chat/ProviderSelect.vue'
import { uiInput } from '@/components/ui/input'
import { useAIChat } from '@/composables/use-chat'
const {
providerID,
providerDef,
apiKey,
setAPIKey,
customBaseURL,
customModelID,
customAPIType,
maxOutputTokens
} = useAIChat()
const keyInput = ref('')
const baseURLInput = ref(customBaseURL.value)
const customModelInput = ref(customModelID.value)
const hasExistingKey = ref(!!apiKey.value)
watch(providerID, () => {
keyInput.value = ''
hasExistingKey.value = !!apiKey.value
baseURLInput.value = customBaseURL.value
customModelInput.value = customModelID.value
})
function save() {
if (keyInput.value.trim()) {
setAPIKey(keyInput.value.trim())
hasExistingKey.value = true
keyInput.value = ''
}
if (providerDef.value.supportsCustomBaseURL) {
customBaseURL.value = baseURLInput.value.trim()
}
if (providerDef.value.supportsCustomModel) {
customModelID.value = customModelInput.value.trim()
}
}
function clearKey() {
setAPIKey('')
keyInput.value = ''
hasExistingKey.value = false
}
</script>
<template>
<PopoverRoot>
<PopoverTrigger
data-test-id="provider-settings-trigger"
class="rounded p-0.5 text-muted hover:bg-hover hover:text-surface"
title="Provider settings"
>
<icon-lucide-settings class="size-3" />
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="top"
:side-offset="8"
align="end"
:collision-padding="16"
:avoid-collisions="true"
class="isolate z-[51] w-64 rounded-lg border border-border bg-panel p-3 shadow-lg"
@interact-outside="
(e: Event) => {
const target = e.target as HTMLElement | null
if (target?.closest('[role=listbox], [data-reka-popper-content-wrapper]')) {
e.preventDefault()
return
}
save()
}
"
>
<div class="flex flex-col gap-2.5">
<h3 class="text-[11px] font-semibold text-surface">AI Provider</h3>
<ProviderSelect test-id="provider-settings-provider" />
<!-- Base URL (OpenAI-compatible only) -->
<div v-if="providerDef.supportsCustomBaseURL" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">Base URL</label>
<input
v-model="baseURLInput"
type="text"
data-test-id="provider-settings-base-url"
placeholder="http://localhost:11434/v1"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
</div>
<!-- Custom model ID (OpenAI-compatible only) -->
<div v-if="providerDef.supportsCustomModel" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">Model ID</label>
<input
v-model="customModelInput"
type="text"
data-test-id="provider-settings-custom-model"
placeholder="e.g. llama-3.3-70b"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
</div>
<!-- API type (OpenAI-compatible only) -->
<div v-if="providerID === 'openai-compatible'" class="flex flex-col gap-1">
<label class="text-[10px] text-muted">API Type</label>
<TabsRoot
:model-value="customAPIType"
data-test-id="provider-settings-api-type"
class="flex flex-col"
@update:model-value="
(v: string) => {
customAPIType = v as 'completions' | 'responses'
save()
}
"
>
<TabsList class="flex rounded bg-canvas">
<TabsTrigger
value="completions"
class="flex-1 rounded px-2 py-1 text-[10px] text-muted data-[state=active]:bg-hover data-[state=active]:text-surface"
>
Completions
</TabsTrigger>
<TabsTrigger
value="responses"
class="flex-1 rounded px-2 py-1 text-[10px] text-muted data-[state=active]:bg-hover data-[state=active]:text-surface"
>
Responses
</TabsTrigger>
</TabsList>
<TabsContent value="completions" />
<TabsContent value="responses" />
</TabsRoot>
</div>
<!-- Max output tokens -->
<div class="flex flex-col gap-1">
<label class="text-[10px] text-muted">Max output tokens</label>
<input
v-model.number="maxOutputTokens"
type="number"
data-test-id="provider-settings-max-tokens"
:min="1024"
:max="128000"
:step="1024"
:class="uiInput({ size: 'sm' })"
/>
</div>
<!-- API key -->
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">API Key</label>
<button
v-if="apiKey"
class="cursor-pointer text-[10px] text-muted hover:text-surface"
data-test-id="provider-settings-clear-key"
@click="clearKey"
>
Clear
</button>
</div>
<input
v-model="keyInput"
type="password"
data-test-id="provider-settings-api-key"
:placeholder="
hasExistingKey ? 'Key saved — enter new to replace' : providerDef.keyPlaceholder
"
:class="uiInput({ size: 'sm' })"
@change="save"
/>
<a
v-if="providerDef.keyURL"
:href="providerDef.keyURL"
target="_blank"
class="text-[9px] text-muted underline hover:text-surface"
>
Get API key →
</a>
</div>
<PopoverClose
class="mt-1 w-full rounded bg-accent px-2 py-1 text-center text-[11px] font-medium text-white hover:bg-accent/90"
data-test-id="provider-settings-done"
@click="save"
>
Done
</PopoverClose>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>