import { LitElement, html, css, unsafeCSS } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import { repeat } from 'lit/directives/repeat.js';
import {
getAIModels,
updateAIModel,
createAIModel,
deleteAIModel,
getAvailableModelsForProvider,
} from '../../../api';
import type { AIModel } from '../../../types';
import type { SlSelect } from '@shoelace-style/shoelace/dist/components/select/select.js';
import type { SlInput } from '@shoelace-style/shoelace/dist/components/input/input.js';
import '@shoelace-style/shoelace/dist/components/dialog/dialog.js';
import '@shoelace-style/shoelace/dist/components/button/button.js';
import '@shoelace-style/shoelace/dist/components/input/input.js';
import '@shoelace-style/shoelace/dist/components/select/select.js';
import '@shoelace-style/shoelace/dist/components/option/option.js';
import '@shoelace-style/shoelace/dist/components/card/card.js';
import '@shoelace-style/shoelace/dist/components/icon/icon.js';
import '@shoelace-style/shoelace/dist/components/spinner/spinner.js';
import '@shoelace-style/shoelace/dist/components/badge/badge.js';
import '@shoelace-style/shoelace/dist/components/alert/alert.js';
import consoleStyles from '../../../styles/console-styles.css?inline';
@customElement('ai-models-view')
export class AIModelsView extends LitElement {
private readonly INFO_ALERT_DISMISSED_KEY =
'preloop-models-info-alert-dismissed';
@state()
private _isInfoAlertOpen = false;
@state()
private models: AIModel[] = [];
@state()
private isLoading = true;
@state()
private error: string | null = null;
@state()
private isModalOpen = false;
@state()
private isEditing = false;
@state()
private currentModel: Partial<AIModel> = {};
@state()
private isDeleteConfirmOpen = false;
@state()
private modelToDelete: AIModel | null = null;
@state()
private modelSuggestions: string[] = [];
@state()
private isOtherModel = false;
@state()
private formError: string | null = null;
@state()
private isSubmitting = false;
@state()
private isFetchingModels = false;
@state()
private modelsFetchError: string | null = null;
static styles = [
unsafeCSS(consoleStyles),
css`
table {
width: 100%;
border-collapse: collapse;
}
.styled-table th,
.styled-table td {
padding: var(--sl-spacing-medium);
text-align: left;
border-bottom: 1px solid var(--sl-color-neutral-200);
}
.styled-table th {
background-color: var(--sl-color-neutral-50);
font-weight: var(--sl-font-weight-semibold);
}
.styled-table tr:last-child td {
border-bottom: none;
}
.actions {
display: flex;
gap: var(--sl-spacing-x-small);
justify-content: flex-end;
}
sl-dialog::part(panel) {
width: 620px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-grid .full-width {
grid-column: 1 / -1;
}
.empty-state a {
color: var(--sl-color-primary-600);
text-decoration: none;
cursor: pointer;
}
.empty-state a:hover {
text-decoration: underline;
}
.info-header {
margin-bottom: var(--sl-spacing-large);
}
`,
];
async connectedCallback() {
super.connectedCallback();
const isDismissed = localStorage.getItem(this.INFO_ALERT_DISMISSED_KEY);
this._isInfoAlertOpen = isDismissed !== 'true';
await this.fetchModels();
}
async fetchModels() {
this.isLoading = true;
this.error = null;
try {
this.models = await getAIModels();
} catch (error) {
this.error =
error instanceof Error ? error.message : 'Failed to fetch AI models';
} finally {
this.isLoading = false;
}
}
render() {
const renderContent = () => {
if (this.isLoading) {
return html`<sl-card
><div style="display: flex; justify-content: center; padding: 2rem;">
<sl-spinner></sl-spinner></div
></sl-card>`;
}
if (this.error) {
return html`
<sl-alert variant="danger" open>
<sl-icon slot="icon" name="exclamation-octagon"></sl-icon>
<strong>Error:</strong> ${this.error}
</sl-alert>
`;
}
return this.renderModelsList();
};
return html`
<view-header headerText="AI Models" width="narrow">
<div slot="main-column">
<sl-button variant="primary" @click=${this.openAddModelModal}>
<sl-icon slot="prefix" name="plus-lg"></sl-icon> Add Model
</sl-button>
</div>
</view-header>
<div class="column-layout narrow">
<div class="main-column">${renderContent()}</div>
<div class="side-column"></div>
</div>
${this.renderModal()} ${this.renderDeleteConfirm()}
`;
}
renderModelsList() {
return html`
<sl-card class="table-card">
${when(
this.models.length === 0,
() =>
html` <sl-alert variant="primary" open>
<sl-icon slot="icon" name="info-circle"></sl-icon>
No AI Models configured yet.
<a
href="#"
@click=${(e: Event) => {
e.preventDefault();
this.openAddModelModal();
}}
>Add a Model</a
>
</sl-alert>`,
() => html`
<table class="styled-table">
<thead>
<tr>
<th>Name</th>
<th>Provider</th>
<th>Model</th>
<th>Default</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${repeat(
this.models,
(model) => model.id,
(model) => html`
<tr>
<td>${model.name}</td>
<td>${model.provider_name}</td>
<td>${model.model_identifier}</td>
<td>
${when(
model.is_default,
() =>
html`<sl-badge variant="success" pill
>Default</sl-badge
>`,
() => html`
<sl-button
size="small"
@click=${() => this.handleSetDefault(model)}
>
Set as default
</sl-button>
`
)}
</td>
<td>
<div class="actions">
<sl-button
size="small"
circle
@click=${() => this.openEditModal(model)}
>
<sl-icon name="pencil"></sl-icon>
</sl-button>
<sl-button
variant="danger"
size="small"
circle
@click=${() => this.openDeleteConfirm(model)}
>
<sl-icon name="trash"></sl-icon>
</sl-button>
</div>
</td>
</tr>
`
)}
</tbody>
</table>
`
)}
</sl-card>
`;
}
renderModal() {
return html`
<sl-dialog
label="${this.isEditing ? 'Edit' : 'Add'} a Model"
.open=${this.isModalOpen}
>
${when(
this.formError,
() => html`
<sl-alert variant="danger" open>
<sl-icon slot="icon" name="exclamation-octagon"></sl-icon>
<strong>Error:</strong> ${this.formError}
</sl-alert>
`
)}
<div class="form-grid">
<sl-input
class="full-width"
label="Friendly Name"
.value=${this.currentModel.name || ''}
@sl-input=${(e: Event) =>
(this.currentModel.name = (e.target as HTMLInputElement).value)}
?disabled=${this.isSubmitting}
></sl-input>
<sl-select
label="Provider"
.value=${this.currentModel.provider_name || ''}
@sl-change=${this.handleProviderChange}
?disabled=${this.isSubmitting}
>
<sl-option value="openai">OpenAI</sl-option>
<sl-option value="anthropic">Anthropic</sl-option>
<sl-option value="google">Google</sl-option>
<sl-option value="qwen">Qwen</sl-option>
<sl-option value="deepseek">DeepSeek</sl-option>
<sl-option value="custom">Custom</sl-option>
</sl-select>
<sl-input
class="full-width"
label="API URL"
.value=${this.currentModel.api_url || ''}
@sl-input=${(e: Event) =>
(this.currentModel.api_url = (
e.target as HTMLInputElement
).value)}
?disabled=${this.isSubmitting}
></sl-input>
<sl-input
class="full-width"
type="password"
label="API Key"
.value=${this.currentModel.api_key || ''}
@sl-input=${(e: Event) => {
this.currentModel.api_key = (e.target as HTMLInputElement).value;
this.requestUpdate();
console.log(this.currentModel.api_key);
}}
placeholder=${this.isEditing
? 'Leave blank to keep existing key'
: ''}
?disabled=${this.isSubmitting}
help-text=${this.isEditing
? ''
: 'Enter your API key to fetch available models'}
></sl-input>
<div class="full-width">
<sl-button
@click=${this.fetchModelsForCurrentProvider}
?loading=${this.isFetchingModels}
?disabled=${this.isSubmitting || this.isFetchingModels}
style="width: 100%;"
>
${this.modelSuggestions.length > 0
? 'Refresh Models'
: 'Fetch Available Models'}
</sl-button>
${this.modelsFetchError
? html`
<div
style="color: var(--sl-color-danger-600); font-size: 0.875rem; margin-top: 0.5rem;"
>
${this.modelsFetchError}
</div>
`
: ''}
</div>
${this.modelSuggestions.length > 0
? html`
<sl-select
class="full-width"
label="Model Name / ID"
.value=${this.isOtherModel
? 'other'
: this.currentModel.model_identifier || ''}
@sl-change=${this._handleModelNameChange}
?disabled=${this.isSubmitting}
>
${repeat(
this.modelSuggestions,
(s) => s,
(s) => html`<sl-option value="${s}">${s}</sl-option>`
)}
<sl-option value="other">Other...</sl-option>
</sl-select>
${when(
this.isOtherModel,
() => html`
<sl-input
class="full-width"
label="Custom Model Name / ID"
placeholder="Enter custom model name"
.value=${this.currentModel.model_identifier || ''}
@sl-input=${this._handleCustomModelInput}
?disabled=${this.isSubmitting}
></sl-input>
`
)}
`
: ''}
</div>
<sl-button
slot="footer"
@click=${this.closeModal}
?disabled=${this.isSubmitting}
>Cancel</sl-button
>
<sl-button
slot="footer"
variant="primary"
@click=${this.handleFormSubmit}
?loading=${this.isSubmitting}
?disabled=${this.isSubmitting}
>Save</sl-button
>
</sl-dialog>
`;
}
renderDeleteConfirm() {
return html`
<sl-dialog
label="Delete Model"
.open=${this.isDeleteConfirmOpen}
@sl-hide=${() => (this.isDeleteConfirmOpen = false)}
>
Are you sure you want to delete the model "${this.modelToDelete?.name}"?
<sl-button
slot="footer"
@click=${() => (this.isDeleteConfirmOpen = false)}
>Cancel</sl-button
>
<sl-button slot="footer" variant="danger" @click=${this.deleteModel}
>Delete</sl-button
>
</sl-dialog>
`;
}
openAddModelModal() {
this.isEditing = false;
this.currentModel = {};
this.modelSuggestions = [];
this.isOtherModel = false;
this.formError = null;
this.isSubmitting = false;
this.isModalOpen = true;
}
async openEditModal(model: AIModel) {
this.isEditing = true;
this.currentModel = { ...model };
// Don't auto-fetch models when editing - user can fetch if needed
this.modelSuggestions = [];
this.isOtherModel = false;
this.formError = null;
this.isSubmitting = false;
this.modelsFetchError = null;
this.isFetchingModels = false;
this.isModalOpen = true;
}
closeModal() {
this.isModalOpen = false;
this.formError = null;
this.isSubmitting = false;
}
private _handleModelNameChange(e: Event) {
const selectedValue = (e.target as SlSelect).value;
if (selectedValue === 'other') {
this.isOtherModel = true;
this.currentModel.model_identifier = '';
} else {
this.isOtherModel = false;
this.currentModel.model_identifier = selectedValue;
}
}
private _handleCustomModelInput(e: Event) {
this.currentModel.model_identifier = (e.target as SlInput).value;
}
private async fetchModelSuggestionsForProvider(
provider: string
): Promise<string[]> {
try {
// Fetch available models from the provider
const models = await getAvailableModelsForProvider(provider);
return models;
} catch (error) {
console.error(`Failed to fetch models for ${provider}:`, error);
// Return fallback list on error
switch (provider) {
case 'openai':
return [
'gpt-4o',
'gpt-4o-mini',
'gpt-4-turbo',
'gpt-4',
'gpt-3.5-turbo',
];
case 'anthropic':
return [
'claude-3-7-sonnet-20250219',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
'claude-3-opus-20240229',
'claude-3-sonnet-20240229',
'claude-3-haiku-20240307',
];
case 'google':
return [
'gemini-2.0-flash-exp',
'gemini-1.5-pro-latest',
'gemini-1.5-flash-latest',
'gemini-1.5-flash-8b-latest',
'gemini-1.0-pro',
];
case 'qwen':
return ['qwen-plus', 'qwen-turbo', 'qwen-max', 'qwq-32b-preview'];
case 'deepseek':
return ['deepseek-chat', 'deepseek-reasoner'];
default:
return [];
}
}
}
async fetchModelsForCurrentProvider() {
if (!this.currentModel.provider_name) {
return;
}
this.isFetchingModels = true;
this.modelsFetchError = null;
try {
this.modelSuggestions = await this.fetchModelSuggestionsForProvider(
this.currentModel.provider_name
);
if (this.modelSuggestions.length === 0) {
this.modelsFetchError = 'No models available for this provider';
}
} catch (error) {
console.error('Failed to fetch models:', error);
this.modelsFetchError =
error instanceof Error ? error.message : 'Failed to fetch models';
} finally {
this.isFetchingModels = false;
this.requestUpdate();
}
}
async handleProviderChange(e: Event) {
const provider = (e.target as SlSelect).value;
const defaultUrls: { [key: string]: string } = {
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com/v1',
google: 'https://generativelanguage.googleapis.com/v1beta',
qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
deepseek: 'https://api.deepseek.com/v1',
};
this.currentModel = {
...this.currentModel,
provider_name: provider,
api_url: defaultUrls[provider] || '',
model_identifier: '',
};
// Reset model suggestions and errors
this.modelSuggestions = [];
this.isOtherModel = false;
this.modelsFetchError = null;
this.requestUpdate();
}
async handleFormSubmit(e: Event) {
e.preventDefault();
// Clear previous error
this.formError = null;
// Basic validation
if (
!this.currentModel.name ||
!this.currentModel.provider_name ||
!this.currentModel.model_identifier ||
!this.currentModel.api_url
) {
this.formError = 'Please fill in all required fields';
return;
}
this.isSubmitting = true;
try {
if (this.isEditing) {
await updateAIModel(this.currentModel.id!, this.currentModel);
} else {
await createAIModel(this.currentModel);
}
this.closeModal();
await this.fetchModels();
} catch (error) {
// Extract error message from the Error object or response
if (error instanceof Error) {
this.formError = error.message;
} else {
this.formError = 'Failed to save model. Please try again.';
}
console.error('Failed to save model:', error);
} finally {
this.isSubmitting = false;
}
}
openDeleteConfirm(model: AIModel) {
this.modelToDelete = model;
this.isDeleteConfirmOpen = true;
}
async handleSetDefault(model: AIModel) {
try {
await updateAIModel(model.id, { is_default: true });
await this.fetchModels();
} catch (error) {
console.error('Failed to set default model:', error);
this.error =
error instanceof Error ? error.message : 'Failed to set default model';
}
}
async deleteModel() {
if (this.modelToDelete) {
try {
await deleteAIModel(this.modelToDelete.id);
await this.fetchModels();
} catch (error) {
console.error('Failed to delete model:', error);
this.error =
error instanceof Error ? error.message : 'Failed to delete model';
}
}
this.isDeleteConfirmOpen = false;
this.modelToDelete = null;
}
private handleInfoAlertHide() {
localStorage.setItem(this.INFO_ALERT_DISMISSED_KEY, 'true');
this._isInfoAlertOpen = false;
}
}