Files
temetro/backend/src/services/ai/config.ts
T
Khalid Abdi b299501ab2 frontend+backend: patients/settings UI, AI Auto/Off modes, wallet doc push
- patients: move status filter to its own "Filter" row above the table
- patient sheet: name + ⋯ menu on one left row; Edit moved into the ⋯ menu
- settings: redesign sections with the COSS CardFrame surface (SettingsFrame)
- AI: add Automatic (auto-pick provider) and Off modes; default to Automatic
  so a fresh install shows the setup banner until a provider is configured
- AI: fix the setup banner never showing (defaulted Ollama URL counted as
  configured); add an "AI off" notice; add a fallback render for unknown chat
  data parts so cards never silently vanish
- backend: include patient attachment metadata in the wallet record-update
  bundle so pushed documents reach the wallet
- i18n: add mode/off/card keys across all five locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:36 +03:00

112 lines
3.5 KiB
TypeScript

import { eq } from "drizzle-orm";
import { db } from "../../db/index.js";
import { userAiSettings } from "../../db/schema/ai.js";
import { decryptSecret, encryptSecret } from "../../lib/crypto.js";
import type { AiConfigInput } from "../../lib/ai-validation.js";
import {
type AiConfig,
type ApiProvider,
DEFAULT_OLLAMA_BASE_URL,
} from "../../types/ai.js";
type AiSettingsRow = typeof userAiSettings.$inferSelect;
const DEFAULTS: Omit<AiSettingsRow, "userId" | "updatedAt"> = {
// Default to auto: use a cloud key if the user adds one, else local Ollama.
// A fresh user with nothing configured then sees the setup banner.
mode: "auto",
provider: "anthropic",
ollamaBaseUrl: DEFAULT_OLLAMA_BASE_URL,
ollamaModel: "llama3.1",
defaultModel: "claude-sonnet-4-6",
defaultEffort: "medium",
veilLevel: "full",
apiKeysCipher: {},
};
// The full row for a user (including the encrypted key map), with defaults when
// the user has never saved AI settings. Internal — never returned to clients.
export async function getAiSettings(userId: string): Promise<AiSettingsRow> {
const [row] = await db
.select()
.from(userAiSettings)
.where(eq(userAiSettings.userId, userId))
.limit(1);
return row ?? { userId, updatedAt: new Date(), ...DEFAULTS };
}
const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
// Strips secrets and reports which providers have a stored key.
export function toAiConfig(row: AiSettingsRow): AiConfig {
const apiKeySet = Object.fromEntries(
PROVIDERS.map((p) => [p, Boolean(row.apiKeysCipher[p])]),
) as Record<ApiProvider, boolean>;
return {
mode: row.mode,
provider: row.provider,
ollamaBaseUrl: row.ollamaBaseUrl,
ollamaModel: row.ollamaModel,
defaultModel: row.defaultModel,
defaultEffort: row.defaultEffort,
veilLevel: row.veilLevel,
apiKeySet,
};
}
// Decrypts the stored key for a provider, or null if none/undecryptable.
export function getApiKey(
row: AiSettingsRow,
provider: ApiProvider,
): string | null {
const cipher = row.apiKeysCipher[provider];
if (!cipher) return null;
try {
return decryptSecret(cipher);
} catch {
return null;
}
}
// Upserts a user's AI config. A provided `apiKey` is encrypted and stored for
// the *currently selected* provider (the one in `input.provider`, else the
// existing provider); an empty string clears it. The key is never persisted in
// plaintext and never returned.
export async function saveAiConfig(
userId: string,
input: AiConfigInput,
): Promise<AiConfig> {
const current = await getAiSettings(userId);
const next = {
mode: input.mode ?? current.mode,
provider: input.provider ?? current.provider,
ollamaBaseUrl: input.ollamaBaseUrl ?? current.ollamaBaseUrl,
ollamaModel: input.ollamaModel ?? current.ollamaModel,
defaultModel: input.defaultModel ?? current.defaultModel,
defaultEffort: input.defaultEffort ?? current.defaultEffort,
veilLevel: input.veilLevel ?? current.veilLevel,
apiKeysCipher: { ...current.apiKeysCipher },
};
if (input.apiKey !== undefined) {
const target = input.provider ?? current.provider;
if (input.apiKey === "") {
delete next.apiKeysCipher[target];
} else {
next.apiKeysCipher[target] = encryptSecret(input.apiKey);
}
}
await db
.insert(userAiSettings)
.values({ userId, ...next })
.onConflictDoUpdate({
target: userAiSettings.userId,
set: { ...next, updatedAt: new Date() },
});
return toAiConfig({ userId, updatedAt: new Date(), ...next });
}