feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s

- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
Antigravity
2026-05-16 12:59:30 +00:00
parent 1fcea6ed7d
commit bd495be965
2284 changed files with 395285 additions and 2327 deletions

View File

@@ -4,8 +4,9 @@ import { CustomOpenAIProvider } from './providers/custom-openai';
import { AnthropicProvider } from './providers/anthropic';
import { GoogleProvider } from './providers/google';
import { AIProvider } from './types';
import { resolveAiRoute } from './router';
type ProviderType =
export type ProviderType =
| 'ollama'
| 'openai'
| 'google'
@@ -195,7 +196,8 @@ function createGLMProvider(config: Record<string, string>, modelName: string, em
return new CustomOpenAIProvider(apiKey, defaults.baseUrl, modelName || defaults.model, embeddingModelName || defaults.embeddingModel);
}
function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
/** Exported for tests and Story 3.3+ orchestration; prefer `get*Provider` in production call sites. */
export function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
switch (providerType) {
case 'ollama':
return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
@@ -250,61 +252,15 @@ function getProviderConfigKeys(providerType: string): { apiKeyConfigKey: string;
}
export function getTagsProvider(config?: Record<string, string>): AIProvider {
const providerType = (
config?.AI_PROVIDER_TAGS ||
config?.AI_PROVIDER_EMBEDDING ||
config?.AI_PROVIDER ||
process.env.AI_PROVIDER_TAGS ||
process.env.AI_PROVIDER_EMBEDDING ||
process.env.AI_PROVIDER
);
if (!providerType) {
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
throw new Error(
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
);
}
const provider = providerType.toLowerCase() as ProviderType;
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_TAGS || config?.OLLAMA_BASE_URL;
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
const cfg = config || {};
const route = resolveAiRoute('tags', cfg);
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
}
export function getEmbeddingsProvider(config?: Record<string, string>): AIProvider {
const providerType = (
config?.AI_PROVIDER_EMBEDDING ||
config?.AI_PROVIDER_TAGS ||
config?.AI_PROVIDER ||
process.env.AI_PROVIDER_EMBEDDING ||
process.env.AI_PROVIDER_TAGS ||
process.env.AI_PROVIDER
);
if (!providerType) {
console.error('[getEmbeddingsProvider] FATAL: No provider configured. Config received:', config);
throw new Error(
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
);
}
const provider = providerType.toLowerCase() as ProviderType;
if (provider === 'anthropic' || provider === 'anthropic_custom') {
throw new Error(
'AI_PROVIDER_EMBEDDING cannot use "anthropic" or "anthropic_custom": these gateways use the Anthropic Messages API only (no embeddings in Memento). Use ollama, openai, or "custom" with MiniMax OpenAI URL https://api.minimax.io/v1 for embeddings.'
);
}
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
const cfg = config || {};
const route = resolveAiRoute('embedding', cfg);
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
}
export function getAIProvider(config?: Record<string, string>): AIProvider {
@@ -312,35 +268,9 @@ export function getAIProvider(config?: Record<string, string>): AIProvider {
}
export function getChatProvider(config?: Record<string, string>): AIProvider {
const providerType = (
config?.AI_PROVIDER_CHAT ||
config?.AI_PROVIDER_TAGS ||
config?.AI_PROVIDER_EMBEDDING ||
config?.AI_PROVIDER ||
process.env.AI_PROVIDER_CHAT ||
process.env.AI_PROVIDER_TAGS ||
process.env.AI_PROVIDER_EMBEDDING ||
process.env.AI_PROVIDER
);
if (!providerType) {
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
throw new Error(
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
);
}
const provider = providerType.toLowerCase() as ProviderType;
const modelName = (
config?.AI_MODEL_CHAT ||
process.env.AI_MODEL_CHAT ||
'granite4:latest'
);
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_CHAT || config?.OLLAMA_BASE_URL_TAGS || config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
return getProviderInstance(provider, config || {}, modelName, embeddingModelName, ollamaBaseUrl);
const cfg = config || {};
const route = resolveAiRoute('chat', cfg);
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
}
// Export for use by admin settings form and deploy scripts

View File

@@ -0,0 +1,255 @@
/**
* Provider failover on retriable upstream errors (Story 3.3 — FR18 / NFR-R1).
*
* Story 3.5 BYOK: when user BYOK is active, call with skipSystemFallback: true.
*/
import { APICallError } from 'ai'
import { QuotaExceededError } from '@/lib/entitlements'
import { getProviderInstance, type ProviderType } from './factory'
import {
resolveAiRoute,
VALID_PROVIDERS,
type AiFeatureLane,
type AiGatewayProvider,
type ResolvedAiRoute,
} from './router'
import type { AIProvider } from './types'
export const FALLBACK_BUDGET_MS = 1500
const VALID_PROVIDER_LIST = [...VALID_PROVIDERS].join(', ')
const LANE_FALLBACK_KEYS: Record<
AiFeatureLane,
{ provider: string; model: string; ollamaUrl?: string }
> = {
chat: {
provider: 'AI_PROVIDER_CHAT_FALLBACK',
model: 'AI_MODEL_CHAT_FALLBACK',
ollamaUrl: 'OLLAMA_BASE_URL_CHAT',
},
tags: {
provider: 'AI_PROVIDER_TAGS_FALLBACK',
model: 'AI_MODEL_TAGS_FALLBACK',
ollamaUrl: 'OLLAMA_BASE_URL_TAGS',
},
embedding: {
provider: 'AI_PROVIDER_EMBEDDING_FALLBACK',
model: 'AI_MODEL_EMBEDDING_FALLBACK',
ollamaUrl: 'OLLAMA_BASE_URL_EMBEDDING',
},
}
function pick(config: Record<string, string>, key: string): string | undefined {
const v = config[key]
if (v != null && v !== '') return v
const e = process.env[key]
return e != null && e !== '' ? e : undefined
}
function cfgOnly(config: Record<string, string>, key: string): string | undefined {
const v = config[key]
return v != null && v !== '' ? v : undefined
}
function extractProviderErrorStatusDepth(err: unknown, depth: number): number | undefined {
if (depth > 5) return undefined
if (err instanceof QuotaExceededError) return 402
if (APICallError.isInstance(err)) {
return err.statusCode ?? undefined
}
if (err && typeof err === 'object') {
const o = err as Record<string, unknown>
if (typeof o.statusCode === 'number') return o.statusCode
if (typeof o.status === 'number') return o.status
if (o.cause) return extractProviderErrorStatusDepth(o.cause, depth + 1)
}
return undefined
}
export function extractProviderErrorStatus(err: unknown): number | undefined {
return extractProviderErrorStatusDepth(err, 0)
}
/** True for HTTP 429 and 5xx provider failures; false for quota and other 4xx. */
export function isRetriableProviderError(err: unknown): boolean {
if (err instanceof QuotaExceededError) return false
if (err && typeof err === 'object') {
const code = (err as { code?: string }).code
if (code === 'QUOTA_EXCEEDED') return false
}
const status = extractProviderErrorStatus(err)
if (status === undefined) return false
if (status === 429) return true
if (status >= 500 && status < 600) return true
return false
}
/**
* Resolve secondary route from *_FALLBACK keys only (primary keys untouched).
*/
export function resolveAiFallbackRoute(
lane: AiFeatureLane,
config: Record<string, string>
): ResolvedAiRoute | null {
const keys = LANE_FALLBACK_KEYS[lane]
const providerRaw = pick(config, keys.provider)?.trim()
if (!providerRaw) return null
const providerType = providerRaw.toLowerCase()
if (!VALID_PROVIDERS.has(providerType)) {
throw new Error(
`Unknown fallback provider '${providerRaw}'. Valid options: ${VALID_PROVIDER_LIST}`
)
}
if (lane === 'embedding' && (providerType === 'anthropic' || providerType === 'anthropic_custom')) {
throw new Error(
'AI_PROVIDER_EMBEDDING_FALLBACK cannot use "anthropic" or "anthropic_custom": no embeddings on this gateway.'
)
}
const primary = resolveAiRoute(lane, config)
if (providerType === primary.providerType) return null
const modelName =
pick(config, keys.model) ??
(lane === 'chat'
? pick(config, 'AI_MODEL_CHAT')
: lane === 'tags'
? pick(config, 'AI_MODEL_TAGS')
: pick(config, 'AI_MODEL_EMBEDDING')) ??
primary.modelName
const embeddingModelName =
pick(config, 'AI_MODEL_EMBEDDING_FALLBACK') ??
pick(config, 'AI_MODEL_EMBEDDING') ??
primary.embeddingModelName
const ollamaBaseUrl =
cfgOnly(config, keys.ollamaUrl!) ||
cfgOnly(config, 'OLLAMA_BASE_URL')
return {
lane,
providerType: providerType as AiGatewayProvider,
modelName,
embeddingModelName,
ollamaBaseUrl,
meta: {},
}
}
function getProviderForRoute(config: Record<string, string>, route: ResolvedAiRoute): AIProvider {
return getProviderInstance(
route.providerType as ProviderType,
config,
route.modelName,
route.embeddingModelName,
route.ollamaBaseUrl
)
}
function getPrimaryProvider(lane: AiFeatureLane, config: Record<string, string>): {
provider: AIProvider
route: ResolvedAiRoute
} {
const route = resolveAiRoute(lane, config)
return { route, provider: getProviderForRoute(config, route) }
}
function getSecondaryProvider(lane: AiFeatureLane, config: Record<string, string>): {
provider: AIProvider
route: ResolvedAiRoute
} | null {
try {
const fbRoute = resolveAiFallbackRoute(lane, config)
if (!fbRoute) return null
return { route: fbRoute, provider: getProviderForRoute(config, fbRoute) }
} catch {
return null
}
}
export function shouldLogAiFallback(): boolean {
return process.env.NODE_ENV !== 'production' || process.env.MEMENTO_AI_ROUTE_DEBUG === '1'
}
export function formatAiFallbackDebug(meta: {
lane: AiFeatureLane
primaryProvider: string
secondaryProvider: string
primaryStatus?: number
fallbackMs: number
}): string {
return JSON.stringify(meta)
}
function logFallbackSuccess(meta: {
lane: AiFeatureLane
primaryProvider: string
secondaryProvider: string
primaryStatus?: number
fallbackMs: number
}): void {
if (!shouldLogAiFallback()) return
console.debug('[ai-fallback]', formatAiFallbackDebug(meta))
if (meta.fallbackMs > FALLBACK_BUDGET_MS) {
console.warn(
`[ai-fallback] NFR-R1 budget exceeded: ${meta.fallbackMs.toFixed(1)}ms > ${FALLBACK_BUDGET_MS}ms`
)
}
}
export interface WithAiProviderFallbackOptions {
/** Story 3.5: skip system secondary when user BYOK is active */
skipSystemFallback?: boolean
}
/**
* Run an AI operation on the primary provider; on retriable failure, try secondary once.
*/
export async function withAiProviderFallback<T>(
lane: AiFeatureLane,
config: Record<string, string>,
run: (provider: AIProvider) => Promise<T>,
options?: WithAiProviderFallbackOptions
): Promise<T> {
if (options?.skipSystemFallback) {
const primary = getPrimaryProvider(lane, config)
return run(primary.provider)
}
const primary = getPrimaryProvider(lane, config)
try {
return await run(primary.provider)
} catch (err) {
if (!isRetriableProviderError(err)) throw err
const fallbackStart = performance.now()
const secondary = getSecondaryProvider(lane, config)
if (!secondary) throw err
const primaryStatus = extractProviderErrorStatus(err)
try {
const result = await run(secondary.provider)
logFallbackSuccess({
lane,
primaryProvider: primary.route.providerType,
secondaryProvider: secondary.route.providerType,
primaryStatus,
fallbackMs: performance.now() - fallbackStart,
})
return result
} catch (secondaryErr) {
console.error(
`[ai-fallback] secondary also failed for lane '${lane}':`,
secondaryErr instanceof Error ? secondaryErr.message : secondaryErr
)
throw secondaryErr
}
}
}

View File

@@ -0,0 +1,108 @@
import {
getProviderInstance,
type ProviderType,
} from '@/lib/ai/factory';
import { applyByokToConfig } from '@/lib/byok';
import {
resolveAiRoute,
type AiFeatureLane,
type ResolvedAiRoute,
} from '@/lib/ai/router';
import { withAiProviderFallback } from '@/lib/ai/fallback';
import type { AIProvider } from '@/lib/ai/types';
export interface ProviderForUserResult {
provider: AIProvider;
usedByok: boolean;
route: ResolvedAiRoute;
}
async function resolveProviderForLane(
lane: AiFeatureLane,
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
const cfg = { ...config };
const route = resolveAiRoute(lane, cfg);
let usedByok = false;
if (billingUserId) {
const overlay = await applyByokToConfig(
billingUserId,
route.providerType,
cfg,
);
Object.assign(cfg, overlay.config);
usedByok = overlay.usedByok;
}
const provider = getProviderInstance(
route.providerType as ProviderType,
cfg,
route.modelName,
route.embeddingModelName,
route.ollamaBaseUrl,
);
return { provider, usedByok, route };
}
export async function getChatProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('chat', config, billingUserId);
}
export async function getTagsProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('tags', config, billingUserId);
}
export async function getEmbeddingsProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('embedding', config, billingUserId);
}
/** Run a lane with BYOK overlay; skips system fallback when user key is active. */
export async function willUseByokForLane(
lane: AiFeatureLane,
config: Record<string, string>,
billingUserId?: string,
): Promise<{ providerType: string; usedByok: boolean }> {
if (!billingUserId) {
const route = resolveAiRoute(lane, config)
return { providerType: route.providerType, usedByok: false }
}
const route = resolveAiRoute(lane, config)
const overlay = await applyByokToConfig(billingUserId, route.providerType, config)
return { providerType: route.providerType, usedByok: overlay.usedByok }
}
export async function runLaneWithBillingUser<T>(
lane: AiFeatureLane,
config: Record<string, string>,
billingUserId: string | undefined,
run: (provider: AIProvider) => Promise<T>,
): Promise<{ result: T; usedByok: boolean }> {
if (billingUserId) {
const resolved =
lane === 'chat'
? await getChatProviderForBillingUser(config, billingUserId)
: lane === 'tags'
? await getTagsProviderForBillingUser(config, billingUserId)
: await getEmbeddingsProviderForBillingUser(config, billingUserId);
if (resolved.usedByok) {
const result = await run(resolved.provider);
return { result, usedByok: true };
}
}
const result = await withAiProviderFallback(lane, config, run);
return { result, usedByok: false };
}

View File

@@ -0,0 +1,172 @@
/**
* Central synchronous AI gateway routing (Story 3.2 — FR17 / NFR-P3).
*
* Future (Story 3.5 BYOK): plug user-scoped API keys into resolveAiRoute output / factory instantiation.
*
* Non-goals here (by design):
* - Multi-provider HTTP fallback on 429/500 → Story 3.3
* - BYOK / UserAPIKey decryption → Story 3.5 (extension seam: same resolve output + key source later)
*/
export type AiFeatureLane = 'chat' | 'tags' | 'embedding'
export type AiGatewayProvider =
| 'ollama'
| 'openai'
| 'google'
| 'minimax'
| 'glm'
| 'custom'
| 'deepseek'
| 'openrouter'
| 'mistral'
| 'zai'
| 'lmstudio'
| 'anthropic'
| 'anthropic_custom'
export interface ResolvedAiRoute {
lane: AiFeatureLane
providerType: AiGatewayProvider
modelName: string
embeddingModelName: string
ollamaBaseUrl?: string
meta: {
resolveMs?: number
}
}
export const VALID_PROVIDERS = new Set<string>([
'ollama', 'openai', 'google', 'minimax', 'glm', 'custom',
'deepseek', 'openrouter', 'mistral', 'zai', 'lmstudio',
'anthropic', 'anthropic_custom',
])
const PROVIDER_MODEL_DEFAULTS: Record<string, { model: string; embeddingModel: string }> = {
ollama: { model: 'granite4:latest', embeddingModel: 'embeddinggemma:latest' },
openai: { model: 'gpt-4o-mini', embeddingModel: 'text-embedding-3-small' },
anthropic: { model: 'claude-sonnet-4-6-20250514', embeddingModel: '' },
anthropic_custom: { model: 'claude-sonnet-4-6-20250514', embeddingModel: '' },
deepseek: { model: 'deepseek-chat', embeddingModel: '' },
openrouter: { model: 'openai/gpt-4o-mini', embeddingModel: 'openai/text-embedding-3-small' },
google: { model: 'gemini-1.5-flash', embeddingModel: 'text-embedding-004' },
mistral: { model: 'mistral-small-latest', embeddingModel: 'mistral-embed' },
zai: { model: 'gpt-4o-mini', embeddingModel: 'text-embedding-3-small' },
minimax: { model: 'abab6.5-chat', embeddingModel: '' },
glm: { model: 'glm-4', embeddingModel: 'embedding-2' },
lmstudio: { model: '', embeddingModel: '' },
custom: { model: '', embeddingModel: '' },
}
function pick(config: Record<string, string>, key: string): string | undefined {
const v = config[key]
if (v != null && v !== '') return v
const e = process.env[key]
return e != null && e !== '' ? e : undefined
}
function cfgOnly(config: Record<string, string>, key: string): string | undefined {
const v = config[key]
return v != null && v !== '' ? v : undefined
}
const VALID_PROVIDER_LIST = [...VALID_PROVIDERS].join(', ')
export function resolveAiRoute(lane: AiFeatureLane, config: Record<string, string>): ResolvedAiRoute {
let providerRaw: string | undefined
let modelKey: string
let ollamaBaseUrl: string | undefined
if (lane === 'tags') {
providerRaw =
pick(config, 'AI_PROVIDER_TAGS') ||
pick(config, 'AI_PROVIDER_EMBEDDING') ||
pick(config, 'AI_PROVIDER')
modelKey = 'AI_MODEL_TAGS'
ollamaBaseUrl = cfgOnly(config, 'OLLAMA_BASE_URL_TAGS') || cfgOnly(config, 'OLLAMA_BASE_URL')
if (!providerRaw) {
throw new Error(
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ' + VALID_PROVIDER_LIST
)
}
} else if (lane === 'embedding') {
providerRaw =
pick(config, 'AI_PROVIDER_EMBEDDING') ||
pick(config, 'AI_PROVIDER_TAGS') ||
pick(config, 'AI_PROVIDER')
modelKey = 'AI_MODEL_EMBEDDING'
ollamaBaseUrl = cfgOnly(config, 'OLLAMA_BASE_URL_EMBEDDING') || cfgOnly(config, 'OLLAMA_BASE_URL')
if (!providerRaw) {
throw new Error(
'AI_PROVIDER_EMBEDDING is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ' + VALID_PROVIDER_LIST
)
}
} else {
providerRaw =
pick(config, 'AI_PROVIDER_CHAT') ||
pick(config, 'AI_PROVIDER_TAGS') ||
pick(config, 'AI_PROVIDER_EMBEDDING') ||
pick(config, 'AI_PROVIDER')
modelKey = 'AI_MODEL_CHAT'
ollamaBaseUrl =
cfgOnly(config, 'OLLAMA_BASE_URL_CHAT') ||
cfgOnly(config, 'OLLAMA_BASE_URL_TAGS') ||
cfgOnly(config, 'OLLAMA_BASE_URL_EMBEDDING') ||
cfgOnly(config, 'OLLAMA_BASE_URL')
if (!providerRaw) {
throw new Error(
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
'Options: ' + VALID_PROVIDER_LIST
)
}
}
const providerType = providerRaw.toLowerCase()
if (!VALID_PROVIDERS.has(providerType)) {
throw new Error(
`Unknown AI provider '${providerRaw}'. Valid options: ${VALID_PROVIDER_LIST}`
)
}
if (lane === 'embedding' && (providerType === 'anthropic' || providerType === 'anthropic_custom')) {
throw new Error(
'AI_PROVIDER_EMBEDDING cannot use "anthropic" or "anthropic_custom": these gateways use the Anthropic Messages API only (no embeddings in Memento). Use ollama, openai, or "custom" with MiniMax OpenAI URL https://api.minimax.io/v1 for embeddings.'
)
}
const defaults = PROVIDER_MODEL_DEFAULTS[providerType] || { model: '', embeddingModel: '' }
const modelName = pick(config, modelKey) || defaults.model
const embeddingModelName = pick(config, 'AI_MODEL_EMBEDDING') || defaults.embeddingModel
return {
lane,
providerType: providerType as AiGatewayProvider,
modelName,
embeddingModelName,
ollamaBaseUrl,
meta: {},
}
}
export function resolveAiRouteWithTiming(lane: AiFeatureLane, config: Record<string, string>): ResolvedAiRoute {
const t0 = performance.now()
const route = resolveAiRoute(lane, config)
const resolveMs = performance.now() - t0
return {
...route,
meta: { ...route.meta, resolveMs },
}
}
export function formatAiRouteDebug(route: ResolvedAiRoute): string {
return JSON.stringify({
lane: route.lane,
providerType: route.providerType,
modelId: route.modelName,
embeddingModelId: route.embeddingModelName,
resolveMs: route.meta.resolveMs,
})
}

View File

@@ -4,7 +4,7 @@
* Stores embeddings as native pgvector in PostgreSQL.
*/
import { getAIProvider } from '../factory'
import { withAiProviderFallback } from '../fallback'
import { getSystemConfig } from '@/lib/config'
export interface EmbeddingResult {
@@ -23,8 +23,9 @@ export class EmbeddingService {
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
const embedding = await provider.getEmbeddings(text)
const embedding = await withAiProviderFallback('embedding', config, (provider) =>
provider.getEmbeddings(text)
)
return {
embedding,
@@ -45,9 +46,8 @@ export class EmbeddingService {
try {
const config = await getSystemConfig()
const provider = getAIProvider(config)
const embeddings = await Promise.all(
validTexts.map(text => provider.getEmbeddings(text))
const embeddings = await withAiProviderFallback('embedding', config, (provider) =>
Promise.all(validTexts.map((text) => provider.getEmbeddings(text)))
)
return embeddings.map(embedding => ({

View File

@@ -2,7 +2,7 @@ import { tool } from 'ai'
import { z } from 'zod'
import { toolRegistry } from './registry'
import { prisma } from '@/lib/prisma'
import { getTagsProvider } from '@/lib/ai/factory'
import { withAiProviderFallback } from '@/lib/ai/fallback'
import { getSystemConfig } from '@/lib/config'
toolRegistry.register({
@@ -44,7 +44,6 @@ toolRegistry.register({
const lang = locale === 'fr' ? 'français' : locale === 'es' ? 'espagnol' : locale === 'de' ? 'allemand' : locale === 'it' ? 'italien' : locale === 'pt' ? 'portugais' : locale === 'nl' ? 'néerlandais' : locale === 'ru' ? 'russe' : locale === 'zh' ? 'chinois' : locale === 'ja' ? 'japonais' : locale === 'ar' ? 'arabe' : locale === 'fa' ? 'persan' : locale === 'hi' ? 'hindi' : 'English'
const config = await getSystemConfig()
const provider = getTagsProvider(config)
const prompt = `You are a task extraction specialist. Analyze the following notes and extract ALL action items, tasks, and TODOs.
@@ -72,7 +71,9 @@ Format each task as:
- **Deadline**: ...
- **Status**: ...`
const result = await provider.generateText(prompt)
const result = await withAiProviderFallback('tags', config, (provider) =>
provider.generateText(prompt)
)
const summaryTitle = locale === 'fr'
? `Action Items — ${new Date().toLocaleDateString('fr-FR')}`