fix(chat): add saved AI defaults and harden suggestions
This commit is contained in:
@@ -36,6 +36,11 @@
|
||||
user_configured: boolean;
|
||||
};
|
||||
|
||||
type UserAISettingsResponse = {
|
||||
preferred_provider: string | null;
|
||||
preferred_model: string | null;
|
||||
};
|
||||
|
||||
export let embedded = false;
|
||||
export let collectionId: string | undefined = undefined;
|
||||
export let collectionName: string | undefined = undefined;
|
||||
@@ -58,6 +63,10 @@
|
||||
let chatProviders: ChatProviderCatalogConfiguredEntry[] = [];
|
||||
let providerError = '';
|
||||
let selectedProviderDefaultModel = '';
|
||||
let savedDefaultProvider = '';
|
||||
let savedDefaultModel = '';
|
||||
let initialDefaultsApplied = false;
|
||||
let loadedModelsForProvider = '';
|
||||
let showDateSelector = false;
|
||||
let selectedPlaceToAdd: PlaceResult | null = null;
|
||||
let selectedDate = '';
|
||||
@@ -68,13 +77,65 @@
|
||||
}>();
|
||||
|
||||
const MODEL_PREFS_STORAGE_KEY = 'voyage_chat_model_prefs';
|
||||
let initializedModelProvider = '';
|
||||
$: promptTripContext = collectionName || destination || '';
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadConversations(), loadProviderCatalog()]);
|
||||
await Promise.all([loadConversations(), loadProviderCatalog(), loadUserAISettings()]);
|
||||
await applyInitialDefaults();
|
||||
});
|
||||
|
||||
async function loadUserAISettings(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/integrations/ai-settings/', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = (await res.json()) as UserAISettingsResponse[];
|
||||
const first = settings[0];
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
|
||||
savedDefaultProvider = (first.preferred_provider || '').trim().toLowerCase();
|
||||
savedDefaultModel = (first.preferred_model || '').trim();
|
||||
} catch (e) {
|
||||
console.error('Failed to load AI settings:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyInitialDefaults(): Promise<void> {
|
||||
if (initialDefaultsApplied || chatProviders.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
savedDefaultProvider &&
|
||||
chatProviders.some((provider) => provider.id === savedDefaultProvider)
|
||||
) {
|
||||
selectedProvider = savedDefaultProvider;
|
||||
} else {
|
||||
const userConfigured = chatProviders.find((provider) => provider.user_configured);
|
||||
selectedProvider = (userConfigured || chatProviders[0]).id;
|
||||
}
|
||||
|
||||
await loadModelsForProvider(selectedProvider);
|
||||
|
||||
if (savedDefaultModel && selectedProvider === savedDefaultProvider) {
|
||||
selectedModel = availableModels.includes(savedDefaultModel)
|
||||
? savedDefaultModel
|
||||
: selectedProviderDefaultModel || availableModels[0] || '';
|
||||
} else {
|
||||
selectedModel = selectedProviderDefaultModel || availableModels[0] || '';
|
||||
}
|
||||
|
||||
saveModelPref(selectedProvider, selectedModel);
|
||||
loadedModelsForProvider = selectedProvider;
|
||||
initialDefaultsApplied = true;
|
||||
}
|
||||
|
||||
async function loadProviderCatalog(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/chat/providers/', {
|
||||
@@ -98,9 +159,8 @@
|
||||
|
||||
if (usable.length > 0) {
|
||||
providerError = '';
|
||||
if (!selectedProvider || !usable.some((provider) => provider.id === selectedProvider)) {
|
||||
const userConfigured = usable.find((provider) => provider.user_configured);
|
||||
selectedProvider = (userConfigured || usable[0]).id;
|
||||
if (selectedProvider && !usable.some((provider) => provider.id === selectedProvider)) {
|
||||
selectedProvider = '';
|
||||
}
|
||||
} else {
|
||||
selectedProvider = '';
|
||||
@@ -113,24 +173,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelsForProvider() {
|
||||
if (!selectedProvider) {
|
||||
async function loadModelsForProvider(providerId: string) {
|
||||
if (!providerId) {
|
||||
availableModels = [];
|
||||
return;
|
||||
}
|
||||
|
||||
modelsLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/chat/providers/${selectedProvider}/models/`, {
|
||||
const res = await fetch(`/api/chat/providers/${providerId}/models/`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.models && data.models.length > 0) {
|
||||
availableModels = data.models;
|
||||
if (!selectedModel || !availableModels.includes(selectedModel)) {
|
||||
selectedModel = availableModels[0];
|
||||
}
|
||||
} else {
|
||||
availableModels = [];
|
||||
}
|
||||
@@ -142,25 +199,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function loadModelPref(provider: string): string {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(MODEL_PREFS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const prefs = JSON.parse(raw) as Record<string, string>;
|
||||
const value = prefs[provider];
|
||||
return typeof value === 'string' ? value : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function saveModelPref(provider: string, model: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -176,20 +214,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: if (selectedProvider && initializedModelProvider !== selectedProvider) {
|
||||
selectedModel = loadModelPref(selectedProvider) || selectedProviderDefaultModel || '';
|
||||
initializedModelProvider = selectedProvider;
|
||||
}
|
||||
|
||||
$: if (selectedProvider && initializedModelProvider === selectedProvider) {
|
||||
saveModelPref(selectedProvider, selectedModel);
|
||||
}
|
||||
|
||||
$: selectedProviderDefaultModel =
|
||||
chatProviders.find((provider) => provider.id === selectedProvider)?.default_model ?? '';
|
||||
|
||||
$: if (selectedProvider) {
|
||||
void loadModelsForProvider();
|
||||
$: if (
|
||||
selectedProvider &&
|
||||
initialDefaultsApplied &&
|
||||
loadedModelsForProvider !== selectedProvider
|
||||
) {
|
||||
loadedModelsForProvider = selectedProvider;
|
||||
void (async () => {
|
||||
await loadModelsForProvider(selectedProvider);
|
||||
if (!selectedModel || !availableModels.includes(selectedModel)) {
|
||||
selectedModel = selectedProviderDefaultModel || availableModels[0] || '';
|
||||
}
|
||||
saveModelPref(selectedProvider, selectedModel);
|
||||
})();
|
||||
}
|
||||
|
||||
$: if (selectedProvider && initialDefaultsApplied) {
|
||||
saveModelPref(selectedProvider, selectedModel);
|
||||
}
|
||||
|
||||
async function loadConversations() {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
name: string;
|
||||
description?: string;
|
||||
why_fits?: string;
|
||||
category?: string;
|
||||
location?: string;
|
||||
rating?: number | string | null;
|
||||
price_level?: string | null;
|
||||
@@ -118,6 +119,94 @@
|
||||
return nextFilters;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeRating(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const match = value.match(/\d+(\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const parsed = Number(match[0]);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSuggestionItem(value: unknown): SuggestionItem | null {
|
||||
const item = asRecord(value);
|
||||
if (!item) return null;
|
||||
|
||||
const name =
|
||||
normalizeText(item.name) ||
|
||||
normalizeText(item.title) ||
|
||||
normalizeText(item.place_name) ||
|
||||
normalizeText(item.venue);
|
||||
const description =
|
||||
normalizeText(item.description) || normalizeText(item.summary) || normalizeText(item.details);
|
||||
const whyFits =
|
||||
normalizeText(item.why_fits) || normalizeText(item.whyFits) || normalizeText(item.reason);
|
||||
const location =
|
||||
normalizeText(item.location) ||
|
||||
normalizeText(item.address) ||
|
||||
normalizeText(item.neighborhood);
|
||||
const category = normalizeText(item.category);
|
||||
const priceLevel =
|
||||
normalizeText(item.price_level) ||
|
||||
normalizeText(item.priceLevel) ||
|
||||
normalizeText(item.price);
|
||||
const rating = normalizeRating(item.rating ?? item.score);
|
||||
|
||||
const finalName = name || location;
|
||||
if (!finalName) return null;
|
||||
|
||||
return {
|
||||
name: finalName,
|
||||
description: description || undefined,
|
||||
why_fits: whyFits || undefined,
|
||||
category: category || undefined,
|
||||
location: location || undefined,
|
||||
rating,
|
||||
price_level: priceLevel || null
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocationPayload(suggestion: SuggestionItem) {
|
||||
const name =
|
||||
normalizeText(suggestion.name) || normalizeText(suggestion.location) || 'Suggestion';
|
||||
const locationText =
|
||||
normalizeText(suggestion.location) ||
|
||||
getCollectionLocation() ||
|
||||
normalizeText(suggestion.name);
|
||||
const description =
|
||||
normalizeText(suggestion.description) ||
|
||||
normalizeText(suggestion.why_fits) ||
|
||||
(suggestion.category ? `${suggestion.category} suggestion` : '');
|
||||
const rating = normalizeRating(suggestion.rating);
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
location: locationText || name,
|
||||
rating,
|
||||
collections: [collection.id],
|
||||
is_public: false
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSuggestions() {
|
||||
if (!selectedCategory) return;
|
||||
|
||||
@@ -144,7 +233,11 @@
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
suggestions = Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||||
suggestions = Array.isArray(data?.suggestions)
|
||||
? data.suggestions
|
||||
.map((item: unknown) => normalizeSuggestionItem(item))
|
||||
.filter((item: SuggestionItem | null): item is SuggestionItem => item !== null)
|
||||
: [];
|
||||
} catch (_err) {
|
||||
error = $t('suggestions.error');
|
||||
suggestions = [];
|
||||
@@ -180,17 +273,12 @@
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const payload = buildLocationPayload(suggestion);
|
||||
const createLocationResponse = await fetch('/api/locations/', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: suggestion.name,
|
||||
description: suggestion.description || suggestion.why_fits || '',
|
||||
location: suggestion.location || getCollectionLocation() || suggestion.name,
|
||||
collections: [collection.id],
|
||||
is_public: false
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!createLocationResponse.ok) {
|
||||
|
||||
@@ -587,6 +587,14 @@ export type UserRecommendationPreferenceProfile = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UserAISettings = {
|
||||
id: string;
|
||||
preferred_provider: string | null;
|
||||
preferred_model: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CollectionItineraryDay = {
|
||||
id: string;
|
||||
collection: string; // UUID of the collection
|
||||
|
||||
@@ -817,6 +817,13 @@
|
||||
"travel_agent_help_body": "Open a collection and switch to Recommendations to interact with the travel agent for place suggestions.",
|
||||
"travel_agent_help_open_collections": "Open Collections",
|
||||
"travel_agent_help_setup_guide": "Travel agent setup guide",
|
||||
"default_ai_settings_title": "Default AI Provider & Model",
|
||||
"default_ai_settings_desc": "Choose the default AI provider and model used across chat experiences.",
|
||||
"default_ai_no_providers": "No configured AI providers are available yet. Add an API key first.",
|
||||
"default_ai_save": "Save default AI settings",
|
||||
"default_ai_settings_saved": "Default AI settings saved.",
|
||||
"default_ai_settings_error": "Unable to save default AI settings.",
|
||||
"default_ai_provider_required": "Please select a provider.",
|
||||
"travel_preferences": "Travel Preferences",
|
||||
"travel_preferences_desc": "Customize your travel preferences for better AI recommendations",
|
||||
"cuisines": "Favorite Cuisines",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fail, redirect, type Actions } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from '../$types';
|
||||
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
|
||||
import type { ImmichIntegration, User } from '$lib/types';
|
||||
import type { ImmichIntegration, User, UserAISettings } from '$lib/types';
|
||||
import { fetchCSRFToken } from '$lib/index.server';
|
||||
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
|
||||
|
||||
@@ -95,6 +95,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
|
||||
let apiKeys: UserAPIKey[] = [];
|
||||
let apiKeysConfigError: string | null = null;
|
||||
let aiSettings: UserAISettings | null = null;
|
||||
let apiKeysFetch = await fetch(`${endpoint}/api/integrations/api-keys/`, {
|
||||
headers: {
|
||||
Cookie: `sessionid=${sessionId}`
|
||||
@@ -108,6 +109,17 @@ export const load: PageServerLoad = async (event) => {
|
||||
apiKeysConfigError = errorBody.detail ?? 'API key storage is currently unavailable.';
|
||||
}
|
||||
|
||||
let aiSettingsFetch = await fetch(`${endpoint}/api/integrations/ai-settings/`, {
|
||||
headers: {
|
||||
Cookie: `sessionid=${sessionId}`
|
||||
}
|
||||
});
|
||||
|
||||
if (aiSettingsFetch.ok) {
|
||||
const aiSettingsResponse = (await aiSettingsFetch.json()) as UserAISettings[];
|
||||
aiSettings = aiSettingsResponse[0] ?? null;
|
||||
}
|
||||
|
||||
let publicUrlFetch = await fetch(`${endpoint}/public-url/`);
|
||||
let publicUrl = '';
|
||||
if (!publicUrlFetch.ok) {
|
||||
@@ -131,6 +143,7 @@ export const load: PageServerLoad = async (event) => {
|
||||
stravaUserEnabled,
|
||||
apiKeys,
|
||||
apiKeysConfigError,
|
||||
aiSettings,
|
||||
wandererEnabled,
|
||||
wandererExpired
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
let apiKeysConfigError: string | null = data.props.apiKeysConfigError ?? null;
|
||||
let newApiKeyProvider = 'anthropic';
|
||||
let providerCatalog: ChatProviderCatalogEntry[] = [];
|
||||
let defaultAiProvider = (data.props.aiSettings?.preferred_provider ?? '').trim().toLowerCase();
|
||||
let defaultAiModel = (data.props.aiSettings?.preferred_model ?? '').trim();
|
||||
let defaultAiModels: string[] = [];
|
||||
let defaultAiModelsLoading = false;
|
||||
let isSavingDefaultAiSettings = false;
|
||||
let newApiKeyValue = '';
|
||||
let isSavingApiKey = false;
|
||||
let deletingApiKeyId: string | null = null;
|
||||
@@ -70,6 +75,104 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getConfiguredChatProviders() {
|
||||
return providerCatalog.filter(
|
||||
(provider) =>
|
||||
provider.available_for_chat && (provider.user_configured || provider.instance_configured)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadDefaultAiModels(providerId: string) {
|
||||
if (!providerId) {
|
||||
defaultAiModels = [];
|
||||
return;
|
||||
}
|
||||
|
||||
defaultAiModelsLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/chat/providers/${providerId}/models/`);
|
||||
if (!res.ok) {
|
||||
defaultAiModels = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
defaultAiModels = Array.isArray(payload.models) ? (payload.models as string[]) : [];
|
||||
} catch {
|
||||
defaultAiModels = [];
|
||||
} finally {
|
||||
defaultAiModelsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeDefaultAiSettings() {
|
||||
const configuredProviders = getConfiguredChatProviders();
|
||||
if (!configuredProviders.length) {
|
||||
defaultAiProvider = '';
|
||||
defaultAiModel = '';
|
||||
defaultAiModels = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!defaultAiProvider ||
|
||||
!configuredProviders.some((provider) => provider.id === defaultAiProvider)
|
||||
) {
|
||||
defaultAiProvider = configuredProviders[0].id;
|
||||
defaultAiModel = '';
|
||||
}
|
||||
|
||||
await loadDefaultAiModels(defaultAiProvider);
|
||||
if (defaultAiModel && !defaultAiModels.includes(defaultAiModel)) {
|
||||
defaultAiModel = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function onDefaultAiProviderChange() {
|
||||
defaultAiModel = '';
|
||||
await loadDefaultAiModels(defaultAiProvider);
|
||||
}
|
||||
|
||||
async function saveDefaultAiSettings(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!defaultAiProvider) {
|
||||
addToast('error', $t('settings.default_ai_provider_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingDefaultAiSettings = true;
|
||||
try {
|
||||
const res = await fetch('/api/integrations/ai-settings/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
preferred_provider: defaultAiProvider,
|
||||
preferred_model: defaultAiModel || null
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
addToast('error', $t('settings.default_ai_settings_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await res.json();
|
||||
defaultAiProvider = (saved.preferred_provider ?? '').trim().toLowerCase();
|
||||
defaultAiModel = (saved.preferred_model ?? '').trim();
|
||||
await loadDefaultAiModels(defaultAiProvider);
|
||||
if (defaultAiModel && !defaultAiModels.includes(defaultAiModel)) {
|
||||
defaultAiModel = '';
|
||||
}
|
||||
addToast('success', $t('settings.default_ai_settings_saved'));
|
||||
} catch {
|
||||
addToast('error', $t('settings.default_ai_settings_error'));
|
||||
} finally {
|
||||
isSavingDefaultAiSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getApiKeyProviderLabel(provider: string): string {
|
||||
const catalogProvider = providerCatalog.find((entry) => entry.id === provider);
|
||||
if (catalogProvider) {
|
||||
@@ -133,7 +236,8 @@
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
void loadProviderCatalog();
|
||||
await loadProviderCatalog();
|
||||
await initializeDefaultAiSettings();
|
||||
|
||||
if (browser) {
|
||||
const queryParams = new URLSearchParams($page.url.search);
|
||||
@@ -1570,6 +1674,71 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-base-200 rounded-xl mb-6">
|
||||
<h3 class="text-lg font-semibold mb-2">
|
||||
{$t('settings.default_ai_settings_title')}
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
{$t('settings.default_ai_settings_desc')}
|
||||
</p>
|
||||
|
||||
{#if getConfiguredChatProviders().length === 0}
|
||||
<div class="alert alert-warning">
|
||||
<span>{$t('settings.default_ai_no_providers')}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="space-y-4" on:submit={saveDefaultAiSettings}>
|
||||
<div class="form-control">
|
||||
<label class="label" for="default-ai-provider">
|
||||
<span class="label-text font-medium">{$t('settings.provider')}</span>
|
||||
</label>
|
||||
<select
|
||||
id="default-ai-provider"
|
||||
class="select select-bordered select-primary w-full"
|
||||
bind:value={defaultAiProvider}
|
||||
on:change={onDefaultAiProviderChange}
|
||||
>
|
||||
{#each getConfiguredChatProviders() as provider}
|
||||
<option value={provider.id}>{provider.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="default-ai-model">
|
||||
<span class="label-text font-medium">{$t('chat.model_label')}</span>
|
||||
</label>
|
||||
<select
|
||||
id="default-ai-model"
|
||||
class="select select-bordered select-primary w-full"
|
||||
bind:value={defaultAiModel}
|
||||
disabled={defaultAiModelsLoading}
|
||||
>
|
||||
<option value="">{$t('chat.model_placeholder')}</option>
|
||||
{#if defaultAiModelsLoading}
|
||||
<option value="" disabled>Loading...</option>
|
||||
{:else}
|
||||
{#each defaultAiModels as model}
|
||||
<option value={model}>{model}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
disabled={isSavingDefaultAiSettings}
|
||||
>
|
||||
{#if isSavingDefaultAiSettings}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
{$t('settings.default_ai_save')}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-base-200 rounded-xl mb-6">
|
||||
<h3 class="text-lg font-semibold mb-2">MCP Access Token</h3>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
|
||||
Reference in New Issue
Block a user