Files
tg-chat-bot/src/common/user-ai-settings.ts
T

451 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {Environment} from "./environment";
import {UserStore} from "./user-store";
import {AiProvider} from "../model/ai-provider";
import {StoredUser} from "../model/stored-user";
import {resolveAiRuntimeTarget} from "../ai/ai-runtime-target";
import {
DEFAULT_LANGUAGE_CHOICE,
LanguageChoice,
Localization,
} from "./localization";
export const DEFAULT_AI_PROVIDER_CHOICE = "DEFAULT";
export const DEFAULT_AI_CONTEXT_SIZE_CHOICE = "DEFAULT";
export const USER_AI_CONTEXT_SIZE_PRESETS = [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 256000] as const;
export const MIN_USER_AI_CONTEXT_SIZE = 64;
export const MAX_USER_AI_CONTEXT_SIZE = 1_000_000;
export const AI_VOICE_MODE_EXECUTE = "execute";
export const AI_VOICE_MODE_TRANSCRIPT = "transcript";
export type UserAiProviderChoice = AiProvider | typeof DEFAULT_AI_PROVIDER_CHOICE;
export type UserAiContextSizeChoice = number | typeof DEFAULT_AI_CONTEXT_SIZE_CHOICE;
export type UserAiVoiceMode = typeof AI_VOICE_MODE_EXECUTE | typeof AI_VOICE_MODE_TRANSCRIPT;
export type UserInterfaceLanguage = LanguageChoice;
export type UserAiResponseLanguage = LanguageChoice;
export type UserTier = "creator" | "admin" | "user";
export type EffectiveUserAiSettings = {
userId: number;
tier: UserTier;
providerChoice: UserAiProviderChoice;
effectiveProvider: AiProvider;
interfaceLanguage: UserInterfaceLanguage;
responseLanguage: UserAiResponseLanguage;
contextSizeChoice: UserAiContextSizeChoice;
contextSize?: number;
voiceMode: UserAiVoiceMode;
availableProviderChoices: UserAiProviderChoice[];
availableContextSizeChoices: UserAiContextSizeChoice[];
availableVoiceModes: UserAiVoiceMode[];
};
const CREATOR_PROVIDERS: readonly AiProvider[] = [
AiProvider.OLLAMA,
AiProvider.GEMINI,
AiProvider.MISTRAL,
AiProvider.OPENAI,
];
const ADMIN_PROVIDERS: readonly AiProvider[] = [
AiProvider.MISTRAL,
AiProvider.OPENAI,
];
const USER_PROVIDERS: readonly AiProvider[] = [
AiProvider.MISTRAL,
AiProvider.GEMINI,
AiProvider.OLLAMA,
];
export const DEFAULT_INTERFACE_LANGUAGE: UserInterfaceLanguage = DEFAULT_LANGUAGE_CHOICE;
export const DEFAULT_AI_RESPONSE_LANGUAGE: UserAiResponseLanguage = DEFAULT_LANGUAGE_CHOICE;
export const DEFAULT_AI_VOICE_MODE: UserAiVoiceMode = AI_VOICE_MODE_EXECUTE;
export function getUserLanguageChoices(): string[] {
return Localization.languageChoices();
}
export function getUserAiContextSizeChoices(): UserAiContextSizeChoice[] {
return [DEFAULT_AI_CONTEXT_SIZE_CHOICE, ...USER_AI_CONTEXT_SIZE_PRESETS];
}
export function getUserAiVoiceModes(): UserAiVoiceMode[] {
return [AI_VOICE_MODE_EXECUTE, AI_VOICE_MODE_TRANSCRIPT];
}
export function getUserTier(userId: number): UserTier {
if (userId === Environment.CREATOR_ID) return "creator";
if (Environment.ADMIN_IDS.has(userId)) return "admin";
return "user";
}
function allowedProvidersForTier(tier: UserTier): readonly AiProvider[] {
switch (tier) {
case "creator":
return CREATOR_PROVIDERS;
case "admin":
return ADMIN_PROVIDERS;
case "user":
return USER_PROVIDERS;
}
}
export function isAiProviderConfigured(provider: AiProvider): boolean {
const target = resolveAiRuntimeTarget(provider, "chat");
switch (provider) {
case AiProvider.OLLAMA:
return !!target.baseUrl && !!target.model;
case AiProvider.GEMINI:
return !!target.apiKey && !!target.model;
case AiProvider.MISTRAL:
return !!target.apiKey && !!target.model;
case AiProvider.OPENAI:
return !!target.apiKey && !!target.model;
}
}
export function getAvailableAiProviderChoices(userId: number): UserAiProviderChoice[] {
const tier = getUserTier(userId);
const providers = allowedProvidersForTier(tier).filter(isAiProviderConfigured);
return [DEFAULT_AI_PROVIDER_CHOICE, ...providers];
}
export function normalizeAiProviderChoice(value: string | undefined | null): UserAiProviderChoice | undefined {
if (!value) return undefined;
if (value === DEFAULT_AI_PROVIDER_CHOICE) return DEFAULT_AI_PROVIDER_CHOICE;
const providers = Object.values(AiProvider) as string[];
return providers.includes(value) ? value as AiProvider : undefined;
}
export function normalizeAiContextSizeChoice(value: string | number | undefined | null): UserAiContextSizeChoice | undefined {
if (value === undefined || value === null || value === "") return undefined;
let numericValue: number;
if (typeof value === "string") {
const normalized = value.trim();
const lower = normalized.toLowerCase();
if (normalized === DEFAULT_AI_CONTEXT_SIZE_CHOICE || lower === "default" || lower === "auto") {
return DEFAULT_AI_CONTEXT_SIZE_CHOICE;
}
numericValue = Number(normalized);
} else {
numericValue = value;
}
if (!Number.isSafeInteger(numericValue) || numericValue < MIN_USER_AI_CONTEXT_SIZE || numericValue > MAX_USER_AI_CONTEXT_SIZE) {
return undefined;
}
return numericValue;
}
export function normalizeAiVoiceMode(value: string | undefined | null): UserAiVoiceMode | undefined {
if (!value) return undefined;
switch (value.trim().toLowerCase()) {
case AI_VOICE_MODE_EXECUTE:
case "command":
case "commands":
case "ai":
return AI_VOICE_MODE_EXECUTE;
case AI_VOICE_MODE_TRANSCRIPT:
case "transcribe":
case "transcription":
case "text":
return AI_VOICE_MODE_TRANSCRIPT;
default:
return undefined;
}
}
export function normalizeUserLanguageChoice(value: string | undefined | null): UserInterfaceLanguage | undefined {
if (!value) return undefined;
if (value === DEFAULT_LANGUAGE_CHOICE) return DEFAULT_LANGUAGE_CHOICE;
const normalized = Localization.normalizeLocale(value);
return normalized && Localization.isKnownLanguageChoice(normalized)
? normalized
: undefined;
}
export function normalizeInterfaceLanguage(value: string | undefined | null): UserInterfaceLanguage | undefined {
return normalizeUserLanguageChoice(value);
}
export function normalizeAiResponseLanguage(value: string | undefined | null): UserAiResponseLanguage | undefined {
return normalizeUserLanguageChoice(value);
}
export function getProviderChoiceLabel(choice: UserAiProviderChoice): string {
if (choice === DEFAULT_AI_PROVIDER_CHOICE) {
return Localization.text("providerChoice.default", {}, "Default");
}
return choice.charAt(0) + choice.slice(1).toLowerCase();
}
export function getResponseLanguageLabel(language: UserAiResponseLanguage): string {
return Localization.languageLabel(language);
}
export function getContextSizeLabel(choice: UserAiContextSizeChoice): string {
if (choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE) {
return Environment.userSettingsContextSizeDefaultText;
}
return Environment.getUserSettingsContextSizeText(choice);
}
export function getVoiceModeLabel(mode: UserAiVoiceMode): string {
switch (mode) {
case AI_VOICE_MODE_EXECUTE:
return Environment.userSettingsVoiceModeExecuteText;
case AI_VOICE_MODE_TRANSCRIPT:
return Environment.userSettingsVoiceModeTranscriptText;
}
}
export function getInterfaceLanguageLabel(language: UserInterfaceLanguage): string {
return Localization.languageLabel(language);
}
export function getResponseLanguageInstruction(language: UserAiResponseLanguage): string {
const instructions = [
"Language:"
];
if (language === DEFAULT_LANGUAGE_CHOICE) {
instructions.push("Always answer in the language of the users latest message unless explicitly asked otherwise.");
} else {
instructions.push(`Always answer to the user in ${Localization.languageInstructionName(language)}. If the user specifically requests another language, comply with that request.`);
}
return instructions.join("\n");
}
function shouldUpdateInterfaceLanguage(user: StoredUser | null, language: UserInterfaceLanguage): boolean {
return !!user?.interfaceLanguage && user.interfaceLanguage !== language;
}
function shouldUpdateProvider(user: StoredUser | null, choice: UserAiProviderChoice): boolean {
return !!user?.aiProvider && user.aiProvider !== choice;
}
function shouldUpdateLanguage(user: StoredUser | null, language: UserAiResponseLanguage): boolean {
return !!user?.aiResponseLanguage && user.aiResponseLanguage !== language;
}
function shouldUpdateContextSize(user: StoredUser | null, choice: UserAiContextSizeChoice): boolean {
if (!user) return false;
if (choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE && user.aiContextSize === undefined) return false;
return normalizeAiContextSizeChoice(user.aiContextSize) !== choice;
}
function shouldUpdateVoiceMode(user: StoredUser | null, mode: UserAiVoiceMode): boolean {
return !!user?.aiVoiceMode && user.aiVoiceMode !== mode;
}
function contextSizeChoiceToStored(choice: UserAiContextSizeChoice): number | undefined {
return choice === DEFAULT_AI_CONTEXT_SIZE_CHOICE ? undefined : choice;
}
export async function ensureValidUserAiSettings(userId: number): Promise<EffectiveUserAiSettings> {
const user = await UserStore.get(userId);
const availableProviderChoices = getAvailableAiProviderChoices(userId);
let availableContextSizeChoices = getUserAiContextSizeChoices();
const availableVoiceModes = getUserAiVoiceModes();
let providerChoice = normalizeAiProviderChoice(user?.aiProvider) ?? DEFAULT_AI_PROVIDER_CHOICE;
let interfaceLanguage = normalizeInterfaceLanguage(user?.interfaceLanguage) ?? DEFAULT_INTERFACE_LANGUAGE;
let responseLanguage = normalizeAiResponseLanguage(user?.aiResponseLanguage) ?? DEFAULT_AI_RESPONSE_LANGUAGE;
let contextSizeChoice = normalizeAiContextSizeChoice(user?.aiContextSize) ?? DEFAULT_AI_CONTEXT_SIZE_CHOICE;
let voiceMode = normalizeAiVoiceMode(user?.aiVoiceMode) ?? DEFAULT_AI_VOICE_MODE;
if (!availableProviderChoices.includes(providerChoice)) {
providerChoice = availableProviderChoices[0] ?? DEFAULT_AI_PROVIDER_CHOICE;
}
if (!Localization.isKnownLanguageChoice(interfaceLanguage)) {
interfaceLanguage = DEFAULT_INTERFACE_LANGUAGE;
}
if (!Localization.isKnownLanguageChoice(responseLanguage)) {
responseLanguage = DEFAULT_AI_RESPONSE_LANGUAGE;
}
if (contextSizeChoice !== DEFAULT_AI_CONTEXT_SIZE_CHOICE && !normalizeAiContextSizeChoice(contextSizeChoice)) {
contextSizeChoice = DEFAULT_AI_CONTEXT_SIZE_CHOICE;
}
if (!availableContextSizeChoices.includes(contextSizeChoice)) {
availableContextSizeChoices = [
DEFAULT_AI_CONTEXT_SIZE_CHOICE,
...[
...availableContextSizeChoices.filter(choice => choice !== DEFAULT_AI_CONTEXT_SIZE_CHOICE),
contextSizeChoice,
].sort((a, b) => Number(a) - Number(b)),
];
}
if (!availableVoiceModes.includes(voiceMode)) {
voiceMode = DEFAULT_AI_VOICE_MODE;
}
if (
shouldUpdateProvider(user, providerChoice)
|| shouldUpdateInterfaceLanguage(user, interfaceLanguage)
|| shouldUpdateLanguage(user, responseLanguage)
|| shouldUpdateContextSize(user, contextSizeChoice)
|| shouldUpdateVoiceMode(user, voiceMode)
) {
await UserStore.updateSettings(userId, {
interfaceLanguage,
aiProvider: providerChoice,
aiResponseLanguage: responseLanguage,
aiContextSize: contextSizeChoiceToStored(contextSizeChoice),
aiVoiceMode: voiceMode,
});
}
return {
userId,
tier: getUserTier(userId),
providerChoice,
effectiveProvider: providerChoice === DEFAULT_AI_PROVIDER_CHOICE ? Environment.DEFAULT_AI_PROVIDER : providerChoice,
interfaceLanguage,
responseLanguage,
contextSizeChoice,
contextSize: contextSizeChoiceToStored(contextSizeChoice),
voiceMode,
availableProviderChoices,
availableContextSizeChoices,
availableVoiceModes,
};
}
export async function resolveEffectiveAiProviderForUser(userId: number | undefined): Promise<AiProvider> {
if (!userId) return Environment.DEFAULT_AI_PROVIDER;
return (await ensureValidUserAiSettings(userId)).effectiveProvider;
}
export async function resolveAiResponseLanguageForUser(userId: number | undefined): Promise<UserAiResponseLanguage> {
if (!userId) return DEFAULT_AI_RESPONSE_LANGUAGE;
return (await ensureValidUserAiSettings(userId)).responseLanguage;
}
export async function resolveAiContextSizeForUser(userId: number | undefined): Promise<number | undefined> {
if (!userId) return undefined;
return (await ensureValidUserAiSettings(userId)).contextSize;
}
export async function resolveAiVoiceModeForUser(userId: number | undefined): Promise<UserAiVoiceMode> {
if (!userId) return DEFAULT_AI_VOICE_MODE;
return (await ensureValidUserAiSettings(userId)).voiceMode;
}
export async function resolveInterfaceLocaleForUser(
userId: number | undefined,
telegramLanguageCode?: string,
): Promise<string> {
if (!userId) {
return Localization.resolveLocale(DEFAULT_INTERFACE_LANGUAGE, telegramLanguageCode);
}
const settings = await ensureValidUserAiSettings(userId);
const user = await UserStore.get(userId);
return Localization.resolveLocale(settings.interfaceLanguage, telegramLanguageCode ?? user?.langCode);
}
export async function setUserAiProviderChoice(
userId: number,
choice: UserAiProviderChoice,
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
const settings = await ensureValidUserAiSettings(userId);
if (!settings.availableProviderChoices.includes(choice)) {
return {ok: false, settings};
}
await UserStore.updateSettings(userId, {
interfaceLanguage: settings.interfaceLanguage,
aiProvider: choice,
aiResponseLanguage: settings.responseLanguage,
});
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
}
export async function setUserAiContextSizeChoice(
userId: number,
choice: UserAiContextSizeChoice,
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
const settings = await ensureValidUserAiSettings(userId);
const normalized = normalizeAiContextSizeChoice(choice);
if (!normalized) {
return {ok: false, settings};
}
await UserStore.updateSettings(userId, {
aiContextSize: contextSizeChoiceToStored(normalized),
});
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
}
export async function setUserAiVoiceMode(
userId: number,
mode: UserAiVoiceMode,
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
const settings = await ensureValidUserAiSettings(userId);
if (!getUserAiVoiceModes().includes(mode)) {
return {ok: false, settings};
}
await UserStore.updateSettings(userId, {
aiVoiceMode: mode,
});
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
}
export async function setUserAiResponseLanguage(
userId: number,
language: UserAiResponseLanguage,
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
const settings = await ensureValidUserAiSettings(userId);
if (!Localization.isKnownLanguageChoice(language)) {
return {ok: false, settings};
}
await UserStore.updateSettings(userId, {
interfaceLanguage: settings.interfaceLanguage,
aiProvider: settings.providerChoice,
aiResponseLanguage: language,
});
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
}
export async function setUserInterfaceLanguage(
userId: number,
language: UserInterfaceLanguage,
): Promise<{ ok: boolean; settings: EffectiveUserAiSettings }> {
const settings = await ensureValidUserAiSettings(userId);
if (!Localization.isKnownLanguageChoice(language)) {
return {ok: false, settings};
}
await UserStore.updateSettings(userId, {
interfaceLanguage: language,
aiProvider: settings.providerChoice,
aiResponseLanguage: settings.responseLanguage,
});
return {ok: true, settings: await ensureValidUserAiSettings(userId)};
}