mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-18 14:55:21 +00:00
feat(ai): Phase 1 — provider configuration, installer step, admin + company settings
Foundation for the AI chatbot + text generation feature. Phase 1 is infrastructure only: driver plumbing, configuration storage with encrypted API keys, global vs per-company resolution, admin + company UI pages, and an optional installer wizard step. The chat assistant and text-generation WYSIWYG integration come in later phases.
**Driver plumbing (app/Support/Ai/)** — AiDriver abstract, AiDriverFactory, AiException, AiChatResponse DTO, OpenRouterDriver concrete implementation. OpenRouter is the OpenAI-compatible aggregator that unlocks hundreds of models behind one API key and one request shape — ideal as the default v1 driver. Drivers are extensible the same way exchange rate drivers are: the module Registry's generic registerDriver('ai', ...) machinery plus a typed Registry::registerAiDriver() convenience wrapper (shipped in the upstream invoiceshelf/modules package in a paired commit).
**AiConfigurationService** — mirrors MailConfigurationService shape but with one deliberate deviation: API keys are encrypted at the service layer via Crypt::encryptString before persistence. OpenRouter bearer tokens have much bigger blast radius than SMTP passwords. Same settings / company_settings tables, same global-vs-per-company pattern, same use_custom_ai_config override toggle. Resolution order: global ai_enabled must be YES, then the company either overrides via use_custom_ai_config=YES (and can opt out with ai_enabled=NO inside the override) or inherits the global config.
**Controllers** — Admin/Settings/AiConfigurationController (global CRUD + driver list + test connection), Company/Settings/CompanyAiConfigurationController (per-company override + test), Setup/AiConfigurationController (installer wizard step, skippable with explicit ai_enabled=NO). API key is always masked as '********' in GET responses — the frontend submits the placeholder back on save and the backend preserves the stored value.
**Installer wizard** — new optional step 7 'AI' between Mail and Account. Default OFF with a Skip button. MailView.vue now routes to installation.ai instead of installation.account; installation.ai then routes to installation.account. Step order comment updated in routes.ts.
**Admin + Company settings pages** — AdminAiConfigView (no toggle, always global) and AiConfigView (with use_custom_ai_config BaseSwitchSection that auto-saves OFF). Both share AiConfigurationForm which renders the driver selector, API key input with show/hide, driver-specific config_fields (base_url for OpenRouter), and per-role enable toggles with free-text model inputs backed by a datalist of suggested models from driver metadata.
**Bootstrap endpoint** — adds an ai block to the response: { enabled, chat_enabled, text_generation_enabled }. All three are booleans resolved through AiConfigurationService::resolveForCompany(). Never leaks the API key. Frontend feature flags read from bootstrapData.ai to decide whether to show Phase 2/3 UI.
**Bouncer ability** 'manage ai config' added to SettingsPolicy, gated on isSuperAdmin() (same pattern as manage email config, manage pdf config).
**Tests** (22 new) — Unit: AiDriverFactory resolves built-in + Registry-contributed drivers, rejects unknown, merges availableDrivers. AiConfigurationService: encryption round-trip, resolution order (3 cases: global off, inherit global, override with company key, override with opt-out), makeDriver null/instance cases, listDrivers metadata. Feature: admin save + read with api key masking, preserve-on-placeholder behavior, company toggle ON/OFF semantics, bootstrap ai flags reflect resolution, company opt-out path.
372 tests pass (was 350, +22). Pint clean. npm run build clean. Phase 2 (chat assistant + tool calling) and Phase 3 (WYSIWYG text generation popup) are separate follow-up commits — this one is the foundation only.
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { helpers, required, requiredIf, url as urlValidator } from '@vuelidate/validators'
|
||||
import type {
|
||||
AiConfig,
|
||||
AiDriverConfigField,
|
||||
AiDriverOption,
|
||||
} from '@/scripts/types/ai-config'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
configData?: Partial<AiConfig>
|
||||
isSaving?: boolean
|
||||
isFetchingInitialData?: boolean
|
||||
drivers?: AiDriverOption[]
|
||||
isTesting?: boolean
|
||||
}>(),
|
||||
{
|
||||
configData: () => ({}),
|
||||
isSaving: false,
|
||||
isFetchingInitialData: false,
|
||||
drivers: () => [],
|
||||
isTesting: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit-data': [config: AiConfig]
|
||||
'test-connection': [config: Pick<AiConfig, 'ai_driver' | 'ai_api_key' | 'ai_base_url'>]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const form = reactive<AiConfig>(createDefaults())
|
||||
const showKey = ref(false)
|
||||
|
||||
const selectedDriver = computed<AiDriverOption | undefined>(() =>
|
||||
props.drivers.find((d) => d.value === form.ai_driver),
|
||||
)
|
||||
|
||||
const suggestedModels = computed(() => selectedDriver.value?.suggested_models ?? [])
|
||||
const configFields = computed<AiDriverConfigField[]>(() => selectedDriver.value?.config_fields ?? [])
|
||||
|
||||
const isAiOn = computed(() => form.ai_enabled === 'YES')
|
||||
const isChatOn = computed(() => form.ai_chat_enabled === 'YES')
|
||||
const isTextGenOn = computed(() => form.ai_text_generation_enabled === 'YES')
|
||||
|
||||
const driversList = computed(() =>
|
||||
props.drivers.map((d) => ({ value: d.value, label: t(d.label) })),
|
||||
)
|
||||
|
||||
const modelDatalistId = 'ai-model-suggestions'
|
||||
|
||||
const rules = computed(() => ({
|
||||
ai_driver: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value),
|
||||
),
|
||||
},
|
||||
ai_api_key: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value),
|
||||
),
|
||||
},
|
||||
ai_base_url: {
|
||||
url: helpers.withMessage(t('validation.invalid_url'), (value: string) => {
|
||||
if (!value) return true
|
||||
return urlValidator.$validator(value, {} as never, {} as never)
|
||||
}),
|
||||
},
|
||||
ai_chat_model: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value && isChatOn.value),
|
||||
),
|
||||
},
|
||||
ai_text_generation_model: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value && isTextGenOn.value),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, form)
|
||||
|
||||
function createDefaults(): AiConfig {
|
||||
return {
|
||||
ai_enabled: 'NO',
|
||||
ai_driver: 'openrouter',
|
||||
ai_api_key: '',
|
||||
ai_base_url: '',
|
||||
ai_chat_enabled: 'NO',
|
||||
ai_chat_model: 'openai/gpt-4o',
|
||||
ai_text_generation_enabled: 'NO',
|
||||
ai_text_generation_model: 'openai/gpt-4o-mini',
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateFromProps() {
|
||||
if (!props.configData) return
|
||||
for (const key of Object.keys(form) as Array<keyof AiConfig>) {
|
||||
if (props.configData[key] !== undefined && props.configData[key] !== null) {
|
||||
;(form as Record<string, unknown>)[key] = props.configData[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.configData, hydrateFromProps, { immediate: true, deep: true })
|
||||
|
||||
// When the driver changes, fill in the driver-default base_url if the user hasn't provided one.
|
||||
watch(
|
||||
() => form.ai_driver,
|
||||
(next) => {
|
||||
const driver = props.drivers.find((d) => d.value === next)
|
||||
if (driver?.default_base_url && !form.ai_base_url) {
|
||||
form.ai_base_url = driver.default_base_url
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function onSubmit() {
|
||||
const valid = await v$.value.$validate()
|
||||
if (!valid) return
|
||||
emit('submit-data', { ...form })
|
||||
}
|
||||
|
||||
function onTestConnection() {
|
||||
emit('test-connection', {
|
||||
ai_driver: form.ai_driver,
|
||||
ai_api_key: form.ai_api_key,
|
||||
ai_base_url: form.ai_base_url,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<!-- Global enable -->
|
||||
<div class="mb-8">
|
||||
<BaseSwitch
|
||||
:model-value="isAiOn"
|
||||
class="flex"
|
||||
:label-right="$t('settings.ai.enable')"
|
||||
@update:model-value="form.ai_enabled = $event ? 'YES' : 'NO'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-muted">{{ $t('settings.ai.enable_help') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isAiOn" class="space-y-6">
|
||||
<!-- Provider selection -->
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.ai.driver')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
required
|
||||
:error="v$.ai_driver.$error && v$.ai_driver.$errors[0]?.$message"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="form.ai_driver"
|
||||
:options="driversList"
|
||||
:content-loading="isFetchingInitialData"
|
||||
value-prop="value"
|
||||
label="label"
|
||||
track-by="label"
|
||||
:can-deselect="false"
|
||||
:invalid="v$.ai_driver.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<!-- API key -->
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.ai.api_key')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:help-text="$t('settings.ai.api_key_help')"
|
||||
required
|
||||
:error="v$.ai_api_key.$error && v$.ai_api_key.$errors[0]?.$message"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="form.ai_api_key"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:type="showKey ? 'text' : 'password'"
|
||||
class="flex-1"
|
||||
name="ai_api_key"
|
||||
:invalid="v$.ai_api_key.$error"
|
||||
/>
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="primary-outline"
|
||||
@click="showKey = !showKey"
|
||||
>
|
||||
{{ showKey ? $t('general.hide') : $t('general.show') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseInputGroup>
|
||||
|
||||
<!-- Driver-specific config fields (base_url for OpenRouter, etc.) -->
|
||||
<BaseInputGroup
|
||||
v-for="field in configFields"
|
||||
:key="field.key"
|
||||
:label="$t(field.label)"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-if="field.type === 'text'"
|
||||
:model-value="(form as unknown as Record<string, string>)[`ai_${field.key}`] ?? ''"
|
||||
:placeholder="field.default"
|
||||
type="text"
|
||||
:name="`ai_${field.key}`"
|
||||
@update:model-value="(val: string) => ((form as unknown as Record<string, string>)[`ai_${field.key}`] = val)"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<!-- Role: chat -->
|
||||
<div class="border-t border-line-default pt-6">
|
||||
<h3 class="text-sm font-semibold text-heading mb-3">{{ $t('settings.ai.roles') }}</h3>
|
||||
<p class="text-xs text-muted mb-4">{{ $t('settings.ai.roles_help') }}</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<BaseSwitch
|
||||
:model-value="isChatOn"
|
||||
class="flex"
|
||||
:label-right="$t('settings.ai.chat')"
|
||||
@update:model-value="form.ai_chat_enabled = $event ? 'YES' : 'NO'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-muted">{{ $t('settings.ai.chat_help') }}</p>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="isChatOn"
|
||||
class="mt-3"
|
||||
:label="$t('settings.ai.chat_model')"
|
||||
required
|
||||
:error="v$.ai_chat_model.$error && v$.ai_chat_model.$errors[0]?.$message"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="form.ai_chat_model"
|
||||
type="text"
|
||||
:list="modelDatalistId"
|
||||
:invalid="v$.ai_chat_model.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<!-- Role: text generation -->
|
||||
<div>
|
||||
<BaseSwitch
|
||||
:model-value="isTextGenOn"
|
||||
class="flex"
|
||||
:label-right="$t('settings.ai.text_generation')"
|
||||
@update:model-value="form.ai_text_generation_enabled = $event ? 'YES' : 'NO'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-muted">{{ $t('settings.ai.text_generation_help') }}</p>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="isTextGenOn"
|
||||
class="mt-3"
|
||||
:label="$t('settings.ai.text_generation_model')"
|
||||
required
|
||||
:error="
|
||||
v$.ai_text_generation_model.$error &&
|
||||
v$.ai_text_generation_model.$errors[0]?.$message
|
||||
"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="form.ai_text_generation_model"
|
||||
type="text"
|
||||
:list="modelDatalistId"
|
||||
:invalid="v$.ai_text_generation_model.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<!-- Datalist with suggested models for both inputs -->
|
||||
<datalist :id="modelDatalistId">
|
||||
<option
|
||||
v-for="model in suggestedModels"
|
||||
:key="model.value"
|
||||
:value="model.value"
|
||||
>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 mt-8">
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="isAiOn"
|
||||
:loading="isTesting"
|
||||
:disabled="isTesting || isSaving"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
@click="onTestConnection"
|
||||
>
|
||||
{{ $t('settings.ai.test_connection') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
Reference in New Issue
Block a user