perf: memo GridCard, fuse save fns, fix slash tab active color
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m32s
CI / Deploy production (on server) (push) Has been skipped

This commit is contained in:
Antigravity
2026-06-14 14:06:05 +00:00
parent a8785ed4f1
commit a623454347
120 changed files with 12301 additions and 785 deletions

View File

@@ -19,7 +19,9 @@ export type ProviderType =
| 'zai'
| 'lmstudio'
| 'anthropic'
| 'anthropic_custom';
| 'anthropic_custom'
| 'custom_openai'
| 'custom_anthropic';
// --- Provider defaults ---
const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string; embeddingModel: string }> = {
@@ -204,6 +206,7 @@ export function getProviderInstance(providerType: ProviderType, config: Record<s
case 'openai':
return createOpenAIProvider(config, modelName, embeddingModelName);
case 'custom':
case 'custom_openai':
return createCustomOpenAIProvider(config, modelName, embeddingModelName);
case 'deepseek':
return createDeepSeekProvider(config, modelName, embeddingModelName);
@@ -218,6 +221,7 @@ export function getProviderInstance(providerType: ProviderType, config: Record<s
case 'anthropic':
return createAnthropicProvider(config, modelName);
case 'anthropic_custom':
case 'custom_anthropic':
return createAnthropicCustomProvider(config, modelName);
case 'google':
return createGoogleProvider(config, modelName, embeddingModelName);
@@ -246,7 +250,11 @@ function getProviderConfigKeys(providerType: string): { apiKeyConfigKey: string;
case 'google': return { apiKeyConfigKey: 'GOOGLE_GENERATIVE_AI_API_KEY', baseUrlConfigKey: '' };
case 'minimax': return { apiKeyConfigKey: 'MINIMAX_API_KEY', baseUrlConfigKey: '' };
case 'glm': return { apiKeyConfigKey: 'GLM_API_KEY', baseUrlConfigKey: '' };
case 'custom': return { apiKeyConfigKey: 'CUSTOM_OPENAI_API_KEY', baseUrlConfigKey: 'CUSTOM_OPENAI_BASE_URL' };
case 'custom':
case 'custom_openai':
return { apiKeyConfigKey: 'CUSTOM_OPENAI_API_KEY', baseUrlConfigKey: 'CUSTOM_OPENAI_BASE_URL' };
case 'custom_anthropic':
return { apiKeyConfigKey: 'ANTHROPIC_CUSTOM_API_KEY', baseUrlConfigKey: 'ANTHROPIC_CUSTOM_BASE_URL' };
default: return { apiKeyConfigKey: '', baseUrlConfigKey: 'OLLAMA_BASE_URL' };
}
}

View File

@@ -7,7 +7,7 @@ const PROVIDER_URLS: Record<string, string> = {
openrouter: 'https://openrouter.ai/api/v1',
mistral: 'https://api.mistral.ai/v1',
zai: 'https://api.zukijourney.com/v1',
minimax: 'https://api.minimax.chat/v1',
minimax: 'https://api.minimax.io/v1',
glm: 'https://open.bigmodel.ai/api/paas/v4',
};
@@ -17,13 +17,21 @@ export const PROVIDER_MODEL_SUGGESTIONS: Record<string, string[]> = {
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
google: ['gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-2.0-flash-exp'],
deepseek: ['deepseek-chat', 'deepseek-coder'],
minimax: ['abab6.5-chat', 'abab6.5s-chat'],
minimax: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2-her'],
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest'],
glm: ['glm-4', 'glm-4-flash'],
openrouter: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'],
custom: [],
};
/**
* Result of fetching models - includes whether they came from the real API or fallbacks
*/
export interface FetchModelsResult {
models: string[]
fromApi: boolean // true = fetched from provider API, false = fallback suggestions
}
/**
* Dynamically queries the provider's /models endpoint using the user's API Key
* to fetch their actual available models list instead of relying on hardcoded choices.
@@ -32,21 +40,32 @@ export async function fetchLiveModelsForProvider(
provider: AiGatewayProvider,
apiKey: string,
customBaseUrl?: string
): Promise<string[]> {
): Promise<FetchModelsResult> {
try {
// Anthropic and Google do not expose a public list via a simple key GET /models (or need specific formats)
// We fall back to the popular defaults for those.
if (provider === 'anthropic' || provider === 'anthropic_custom' || provider === 'google') {
const standardProvider = provider === 'anthropic_custom' ? 'anthropic' : provider;
return PROVIDER_MODEL_SUGGESTIONS[standardProvider] ?? [];
if (
provider === 'anthropic' ||
provider === 'anthropic_custom' ||
provider === 'custom_anthropic' ||
provider === 'google' ||
provider === 'minimax'
) {
const standardProvider =
provider === 'anthropic_custom' || provider === 'custom_anthropic'
? 'anthropic'
: provider;
const models = PROVIDER_MODEL_SUGGESTIONS[standardProvider] ?? [];
return { models, fromApi: false };
}
const baseUrl = provider === 'custom'
const baseUrl = (provider === 'custom' || provider === 'custom_openai')
? customBaseUrl?.replace(/\/$/, '')
: PROVIDER_URLS[provider];
if (!baseUrl) {
return PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
return { models, fromApi: false };
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
@@ -63,7 +82,9 @@ export async function fetchLiveModelsForProvider(
});
if (!response.ok) {
return PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
console.warn(`[fetchLiveModelsForProvider] API returned ${response.status} for ${provider}`);
const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
return { models, fromApi: false };
}
const data = await response.json();
@@ -73,11 +94,24 @@ export async function fetchLiveModelsForProvider(
.sort();
if (fetched.length > 0) {
return fetched;
console.log(`[fetchLiveModelsForProvider] Got ${fetched.length} models from ${provider} API:`, fetched);
return { models: fetched, fromApi: true };
} else {
console.warn(`[fetchLiveModelsForProvider] API returned empty data array for ${provider}`);
}
} catch (err) {
console.warn(`[fetchLiveModelsForProvider] Failed to fetch live models for ${provider}, using fallbacks:`, err);
console.warn(`[fetchLiveModelsForProvider] Failed to fetch live models for ${provider}:`, err);
}
return PROVIDER_MODEL_SUGGESTIONS[provider] ?? [];
const fallbackProvider =
provider === 'custom_openai'
? 'openai'
: provider === 'custom_anthropic'
? 'anthropic'
: provider === 'anthropic_custom'
? 'anthropic'
: provider;
const models = PROVIDER_MODEL_SUGGESTIONS[fallbackProvider] ?? [];
return { models, fromApi: false };
}

View File

@@ -1,8 +1,9 @@
import {
getProviderInstance,
getProviderConfigKeys,
type ProviderType,
} from '@/lib/ai/factory';
import { applyByokToConfig } from '@/lib/byok';
import { getAnyActiveByokForUser, hasAnyActiveByok, ByokUnavailableError } from '@/lib/byok';
import {
resolveAiRoute,
type AiFeatureLane,
@@ -17,78 +18,87 @@ export interface ProviderForUserResult {
route: ResolvedAiRoute;
}
/** Resolve the best AI provider for a user's lane — BYOK first, then admin config. */
async function resolveProviderForLane(
lane: AiFeatureLane,
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
const cfg = { ...config };
const route = resolveAiRoute(lane, cfg);
let usedByok = false;
let byokModel: string | null = null;
const adminRoute = resolveAiRoute(lane, cfg);
if (billingUserId) {
const overlay = await applyByokToConfig(
billingUserId,
route.providerType,
cfg,
);
Object.assign(cfg, overlay.config);
usedByok = overlay.usedByok;
byokModel = overlay.model;
// Prefer admin's provider, fallback to any active BYOK key
const byok = await getAnyActiveByokForUser(billingUserId, adminRoute.providerType, lane);
if (byok) {
const { apiKeyConfigKey, baseUrlConfigKey } = getProviderConfigKeys(byok.provider);
const byokCfg: Record<string, string> = { ...cfg };
if (apiKeyConfigKey) byokCfg[apiKeyConfigKey] = byok.plaintext;
if (baseUrlConfigKey && byok.baseUrl) byokCfg[baseUrlConfigKey] = byok.baseUrl;
const resolvedModel = (byok.model && byok.model.trim()) ? byok.model : adminRoute.modelName;
console.log(`[byok] Using BYOK key: provider=${byok.provider} model=${resolvedModel} user=${billingUserId}`);
const provider = getProviderInstance(
byok.provider as ProviderType,
byokCfg,
resolvedModel,
adminRoute.embeddingModelName,
adminRoute.ollamaBaseUrl,
);
return {
provider,
usedByok: true,
route: {
...adminRoute,
providerType: byok.provider as ResolvedAiRoute['providerType'],
modelName: resolvedModel,
},
};
}
// No key resolved — if user HAS active BYOK rows, decryption failed → throw, no silent fallback
const hasByok = await hasAnyActiveByok(billingUserId);
if (hasByok) {
throw new ByokUnavailableError();
}
}
const resolvedModel = byokModel && byokModel.trim() !== '' ? byokModel : route.modelName;
// No BYOK configured → use admin config
const provider = getProviderInstance(
route.providerType as ProviderType,
adminRoute.providerType as ProviderType,
cfg,
resolvedModel,
route.embeddingModelName,
route.ollamaBaseUrl,
adminRoute.modelName,
adminRoute.embeddingModelName,
adminRoute.ollamaBaseUrl,
);
const updatedRoute = { ...route, modelName: resolvedModel };
return { provider, usedByok, route: updatedRoute };
return { provider, usedByok: false, route: adminRoute };
}
async function getChatProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('chat', config, billingUserId);
}
async function getTagsProviderForBillingUser(
config: Record<string, string>,
billingUserId?: string,
): Promise<ProviderForUserResult> {
return resolveProviderForLane('tags', config, billingUserId);
}
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. */
/** Check if a lane will use BYOK for a given user. */
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);
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 }
const route = resolveAiRoute(lane, config);
const byok = await getAnyActiveByokForUser(billingUserId, route.providerType);
return { providerType: byok?.provider ?? route.providerType, usedByok: !!byok };
}
/**
* Run an AI lane with BYOK priority.
* - If user has active BYOK → uses it, no quota counted.
* - If user has BYOK configured but it can't be loaded → throws ByokUnavailableError (no fallback).
* - If user has no BYOK → uses admin config with system fallback.
*/
export async function runLaneWithBillingUser<T>(
lane: AiFeatureLane,
config: Record<string, string>,
@@ -96,12 +106,8 @@ export async function runLaneWithBillingUser<T>(
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);
// May throw ByokUnavailableError — let it propagate, callers should handle it
const resolved = await resolveProviderForLane(lane, config, billingUserId);
if (resolved.usedByok) {
const result = await run(resolved.provider);
@@ -109,6 +115,7 @@ export async function runLaneWithBillingUser<T>(
}
}
// No BYOK configured → use admin config with system fallback
const result = await withAiProviderFallback(lane, config, run);
return { result, usedByok: false };
}

View File

@@ -23,19 +23,36 @@ export class AnthropicProvider implements AIProvider {
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
return object.tags;
return object.tags;
} catch (err) {
console.warn('Anthropic generateObject tags failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content.substring(0, 1500)}"
Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`,
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []);
return arr.map((t: any) => ({
tag: t.tag || t.label || t.name || '',
confidence: t.confidence || t.score || 0.7,
}));
}
} catch (e) {
console.error('Error generating tags (Anthropic):', e);
return [];
@@ -50,18 +67,33 @@ export class AnthropicProvider implements AIProvider {
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1'),
})),
}),
prompt,
});
return object.titles;
return object.titles;
} catch (err) {
console.warn('Anthropic generateObject titles failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: prompt + '\n\nRespond ONLY as a JSON array of title suggestions: [{"title": "Suggested title", "confidence": 0.9}]',
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []);
return arr.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.8),
}));
}
} catch (e) {
console.error('Error generating titles (Anthropic):', e);
return [];

View File

@@ -1,7 +1,7 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { z } from 'zod';
import { generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
import { cleanAIJsonResponse, cleanAITextResponse } from '../utils/clean-ai-response';
export class CustomOpenAIProvider implements AIProvider {
private model: any;
@@ -49,19 +49,20 @@ export class CustomOpenAIProvider implements AIProvider {
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
const { text } = await aiGenerateText({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
Note content: "${content.substring(0, 1500)}"
Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`,
});
return object.tags;
const cleaned = cleanAIJsonResponse(text)
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []);
return arr.map((t: any) => ({
tag: t.tag || t.label || t.name || '',
confidence: t.confidence || t.score || 0.7,
}));
} catch (e) {
console.error('Error generating tags (Custom OpenAI):', e);
return [];
@@ -83,15 +84,15 @@ export class CustomOpenAIProvider implements AIProvider {
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
// Use generateText instead of generateObject — DeepSeek doesn't support
// Use generateText instead of generateObject — DeepSeek/MiniMax don't support
// response_format: json_schema via the OpenAI compat layer
const { text } = await aiGenerateText({
model: this.model,
prompt: prompt,
})
// Parse the JSON array from the text response — strip markdown code fences if present
const parsed = JSON.parse(text.replace(/^```json\n?/,'').replace(/\n?```$/,'').trim())
const cleaned = cleanAIJsonResponse(text)
const parsed = JSON.parse(cleaned)
const titles = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || [])
return titles.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
@@ -103,6 +104,8 @@ export class CustomOpenAIProvider implements AIProvider {
}
}
async generateText(prompt: string): Promise<string> {
try {
const { text } = await aiGenerateText({
@@ -110,7 +113,7 @@ export class CustomOpenAIProvider implements AIProvider {
prompt: prompt,
});
return text.trim();
return cleanAITextResponse(text).trim();
} catch (e) {
console.error('Error generating text (Custom OpenAI):', e);
throw e;

View File

@@ -1,7 +1,7 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject, generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { z } from 'zod';
import { generateText as aiGenerateText, embed, stepCountIs } from 'ai';
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
import { cleanAIJsonResponse, cleanAITextResponse } from '../utils/clean-ai-response';
export class DeepSeekProvider implements AIProvider {
private model: any;
@@ -41,7 +41,7 @@ Return ONLY a JSON array like: [{"tag":"example","confidence":0.9}]
Note content: "${content.substring(0, 1500)}"`,
});
const clean = text.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const clean = cleanAIJsonResponse(text)
const parsed = JSON.parse(clean);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || []);
return arr.map((t: any) => ({
@@ -69,18 +69,18 @@ Note content: "${content.substring(0, 1500)}"`,
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
// Utiliser generateText + parse manuel (generateObject échoue avec les modèles reasoning)
const { text } = await aiGenerateText({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
return object.titles;
const cleaned = cleanAIJsonResponse(text)
const parsed = JSON.parse(cleaned)
const titles = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || [])
return titles.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.5),
}))
} catch (e) {
console.error('Error generating titles (DeepSeek):', e);
return [];
@@ -94,7 +94,7 @@ Note content: "${content.substring(0, 1500)}"`,
prompt: prompt,
});
return text.trim();
return cleanAITextResponse(text).trim();
} catch (e) {
console.error('Error generating text (DeepSeek):', e);
throw e;

View File

@@ -18,19 +18,36 @@ export class GoogleProvider implements AIProvider {
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
return object.tags;
return object.tags;
} catch (err) {
console.warn('Google generateObject tags failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content.substring(0, 1500)}"
Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`,
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []);
return arr.map((t: any) => ({
tag: t.tag || t.label || t.name || '',
confidence: t.confidence || t.score || 0.7,
}));
}
} catch (e) {
console.error('Error generating tags (Google):', e);
return [];
@@ -52,18 +69,33 @@ export class GoogleProvider implements AIProvider {
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
return object.titles;
return object.titles;
} catch (err) {
console.warn('Google generateObject titles failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: prompt + '\n\nRespond ONLY as a JSON array of title suggestions: [{"title": "Suggested title", "confidence": 0.9}]',
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []);
return arr.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.8),
}));
}
} catch (e) {
console.error('Error generating titles (Google):', e);
return [];

View File

@@ -78,7 +78,7 @@ Note content: "${content}"`;
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
const text = data.response;
const text = (data.response || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonMatch) {
@@ -133,7 +133,7 @@ Note content: "${content}"`;
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
const text = data.response;
const text = (data.response || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
if (jsonMatch) {
@@ -162,7 +162,7 @@ Note content: "${content}"`;
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
const data = await response.json();
return data.response.trim();
return (data.response || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
} catch (e) {
console.error('Error generating text (Ollama):', e);
throw e;

View File

@@ -24,19 +24,36 @@ export class OpenAIProvider implements AIProvider {
async generateTags(content: string): Promise<TagSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
tags: z.array(z.object({
tag: z.string().describe('Short tag name in lowercase'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content}"`,
});
return object.tags;
return object.tags;
} catch (err) {
console.warn('OpenAI generateObject tags failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: `Analyze the following note and suggest 1 to 5 relevant tags.
Note content: "${content.substring(0, 1500)}"
Return ONLY a JSON array of tag objects, like: [{"tag":"example","confidence":0.9}]`,
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.tags || parsed.suggestions || []);
return arr.map((t: any) => ({
tag: t.tag || t.label || t.name || '',
confidence: t.confidence || t.score || 0.7,
}));
}
} catch (e) {
console.error('Error generating tags (OpenAI):', e);
return [];
@@ -58,18 +75,33 @@ export class OpenAIProvider implements AIProvider {
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
try {
const { object } = await generateObject({
model: this.model,
schema: z.object({
titles: z.array(z.object({
title: z.string().describe('Suggested title'),
confidence: z.number().min(0).max(1).describe('Confidence level between 0 and 1')
}))
}),
prompt: prompt,
});
return object.titles;
return object.titles;
} catch (err) {
console.warn('OpenAI generateObject titles failed, falling back to generateText:', err);
const { text } = await aiGenerateText({
model: this.model,
prompt: prompt + '\n\nRespond ONLY as a JSON array of title suggestions: [{"title": "Suggested title", "confidence": 0.9}]',
});
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim();
const parsed = JSON.parse(cleaned);
const arr = Array.isArray(parsed) ? parsed : (parsed.titles || parsed.suggestions || []);
return arr.map((t: any) => ({
title: typeof t === 'string' ? t : t.title || t.name || '',
confidence: typeof t === 'number' ? t : (t.confidence || t.score || 0.8),
}));
}
} catch (e) {
console.error('Error generating titles (OpenAI):', e);
return [];

View File

@@ -24,6 +24,8 @@ export type AiGatewayProvider =
| 'lmstudio'
| 'anthropic'
| 'anthropic_custom'
| 'custom_openai'
| 'custom_anthropic'
export interface ResolvedAiRoute {
lane: AiFeatureLane
@@ -40,6 +42,7 @@ export const VALID_PROVIDERS = new Set<string>([
'ollama', 'openai', 'google', 'minimax', 'glm', 'custom',
'deepseek', 'openrouter', 'mistral', 'zai', 'lmstudio',
'anthropic', 'anthropic_custom',
'custom_openai', 'custom_anthropic',
])
const PROVIDER_MODEL_DEFAULTS: Record<string, { model: string; embeddingModel: string }> = {
@@ -56,6 +59,8 @@ const PROVIDER_MODEL_DEFAULTS: Record<string, { model: string; embeddingModel: s
glm: { model: 'glm-4', embeddingModel: 'embedding-2' },
lmstudio: { model: '', embeddingModel: '' },
custom: { model: '', embeddingModel: '' },
custom_openai: { model: 'gpt-4o-mini', embeddingModel: 'text-embedding-3-small' },
custom_anthropic: { model: 'claude-sonnet-4-6-20250514', embeddingModel: '' },
}
function pick(config: Record<string, string>, key: string): string | undefined {

View File

@@ -0,0 +1,217 @@
/**
* ChunkIndexingService — Indexation incrémentale d'une note en fragments.
*
* Inspiré d'AppFlowy flowy-ai/src/embeddings/scheduler.rs.
*
* Fonctionnement :
* 1. Récupère les fragmentIds existants en DB pour la note
* 2. Chunk le contenu actuel (via chunkNoteContent)
* 3. Compare les hash :
* - Inchangés (hash identique) → skip
* - Nouveaux (hash absent) → embed + insert
* - Supprimés (hash en DB mais plus dans le contenu) → delete
* 4. Embed les nouveaux fragments via une queue à concurrence limitée
* 5. Retry avec backoff exponentiel en cas d'erreur API
*
* Garanties :
* - Pas de re-embed des fragments inchangés (économie API)
* - Pas de fragments orphelins (stale supprimés)
* - Pas de race condition (verrou par noteId)
*/
import PQueue from 'p-queue'
import { prisma } from '@/lib/prisma'
import { embeddingService } from './embedding.service'
import {
chunkNoteContent,
type NoteChunk,
} from '@/lib/text/note-chunking'
import {
prepareNoteTextForEmbedding,
} from '@/lib/text/plain-text'
import { Prisma } from '@prisma/client'
const EMBEDDING_CONCURRENCY = 4
const MAX_RETRIES = 3
const RETRY_BASE_DELAY_MS = 1000
const embeddingQueue = new PQueue({ concurrency: EMBEDDING_CONCURRENCY })
const noteLocks = new Map<string, Promise<void>>()
export interface IndexResult {
noteId: string
totalFragments: number
newFragments: number
skipped: number
deleted: number
durationMs: number
}
export class ChunkIndexingService {
/**
* Indexe une note en fragments. Ne re-embed que les fragments modifiés.
*/
async indexNote(
noteId: string,
title: string | null | undefined,
content: string,
): Promise<IndexResult> {
while (noteLocks.has(noteId)) {
await noteLocks.get(noteId)
}
const task = this.doIndexNote(noteId, title, content)
noteLocks.set(noteId, task.then(() => {}, () => {}))
try {
return await task
} finally {
noteLocks.delete(noteId)
}
}
private async doIndexNote(
noteId: string,
title: string | null | undefined,
content: string,
): Promise<IndexResult> {
const start = Date.now()
const plain = prepareNoteTextForEmbedding(title, content)
const newChunks = chunkNoteContent(noteId, plain)
const newFragmentIds = new Set(newChunks.map((c) => c.fragmentId))
const existing = await prisma.noteEmbeddingChunk.findMany({
where: { noteId },
select: { fragmentId: true },
})
const existingIds = new Set<string>(existing.map((e) => e.fragmentId))
const toDelete = [...existingIds].filter((id) => !newFragmentIds.has(id))
const toEmbed = newChunks.filter((c) => !existingIds.has(c.fragmentId))
const skipped = newChunks.filter((c) => existingIds.has(c.fragmentId))
if (toDelete.length > 0) {
await prisma.noteEmbeddingChunk.deleteMany({
where: {
noteId,
fragmentId: { in: toDelete },
},
})
}
const embeddedChunks = await this.embedChunks(toEmbed)
if (embeddedChunks.length > 0) {
await this.upsertChunks(noteId, embeddedChunks)
}
return {
noteId,
totalFragments: newChunks.length,
newFragments: embeddedChunks.length,
skipped: skipped.length,
deleted: toDelete.length,
durationMs: Date.now() - start,
}
}
/**
* Embed une liste de fragments avec queue à concurrence limitée + retry.
*/
private async embedChunks(chunks: NoteChunk[]): Promise<
Array<NoteChunk & { embedding: number[] }>
> {
if (chunks.length === 0) return []
const results = await Promise.all(
chunks.map((chunk) =>
embeddingQueue.add(() => this.embedWithRetry(chunk)),
),
)
return results.filter(
(r): r is NoteChunk & { embedding: number[] } => r !== null,
)
}
private async embedWithRetry(
chunk: NoteChunk,
): Promise<(NoteChunk & { embedding: number[] }) | null> {
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const embedding = await embeddingService.embedText(chunk.content)
return { ...chunk, embedding }
} catch (err: any) {
lastError = err
if (attempt < MAX_RETRIES - 1) {
const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt)
console.warn(
`[ChunkIndexing] Retry ${attempt + 1}/${MAX_RETRIES} for fragment ${chunk.fragmentId} after ${delay}ms: ${err.message}`,
)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
console.error(
`[ChunkIndexing] Failed to embed fragment ${chunk.fragmentId} after ${MAX_RETRIES} attempts: ${lastError?.message}`,
)
return null
}
/**
* Upsert transactionnel des fragments embeddés.
* Utilise $executeRawUnsafe pour la colonne vector(1536).
*/
private async upsertChunks(
noteId: string,
chunks: Array<NoteChunk & { embedding: number[] }>,
): Promise<void> {
for (const chunk of chunks) {
const vecStr = `[${chunk.embedding.join(',')}]`
await prisma.$executeRaw`
INSERT INTO "NoteEmbeddingChunk" (
"id", "noteId", "fragmentId", "chunkIndex",
"content", "charCount", "embedding", "embeddingModel",
"createdAt", "updatedAt"
)
VALUES (
gen_random_uuid(), ${noteId}, ${chunk.fragmentId}, ${chunk.chunkIndex},
${chunk.content}, ${chunk.charCount},
${vecStr}::vector, 'text-embedding-3-small',
now(), now()
)
ON CONFLICT ("noteId", "fragmentId")
DO UPDATE SET
"content" = EXCLUDED."content",
"charCount" = EXCLUDED."charCount",
"embedding" = EXCLUDED."embedding",
"chunkIndex" = EXCLUDED."chunkIndex",
"updatedAt" = now()
`
}
}
/**
* Supprime tous les fragments d'une note.
*/
async deleteNoteChunks(noteId: string): Promise<void> {
await prisma.noteEmbeddingChunk.deleteMany({ where: { noteId } })
}
/**
* Vérifie si une note a déjà des fragments indexés.
*/
async hasChunks(noteId: string): Promise<boolean> {
const count = await prisma.noteEmbeddingChunk.count({
where: { noteId },
take: 1,
})
return count > 0
}
}
export const chunkIndexingService = new ChunkIndexingService()

View File

@@ -36,6 +36,15 @@ export class EmbeddingService {
)
}
/** Embedde un texte simple et retourne le vecteur brut (pour chunks, requêtes, etc.). */
async embedText(text: string): Promise<number[]> {
if (!text || text.trim().length === 0) {
throw new Error('Cannot generate embedding for empty text')
}
const plain = prepareTextForEmbedding(text)
return this.embedPlainText(plain)
}
/**
* Embedding d'une note complète : titre + corps, multi-chunks si l'article dépasse la fenêtre API.
* Ex. 17 679 caractères → 3 chunks → vecteur moyenné (aucune perte de contenu).

View File

@@ -74,7 +74,8 @@ Respond with titles in ${contentLanguage} (same language as the note).`
})
// Parse JSON response
const response = JSON.parse(text)
const cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/^```json\n?/, '').replace(/\n?```$/, '').trim()
const response = JSON.parse(cleaned)
if (!response.suggestions || !Array.isArray(response.suggestions)) {
throw new Error('Invalid response format')

View File

@@ -426,6 +426,7 @@ function separateArchitectureZones(
layout: Map<string, NodeLayoutBox>,
nodes: SimplifiedNode[],
zones: DiagramZone[],
rankdir: 'LR' | 'TB',
): void {
const zoneGroups = zones
.map((zone) => {
@@ -456,21 +457,40 @@ function separateArchitectureZones(
if (zoneGroups.length <= 1) return
zoneGroups.sort((a, b) => a.minY - b.minY)
const zoneGapY = 90
let cursorY = zoneGroups[0].minY
if (rankdir === 'LR') {
zoneGroups.sort((a, b) => a.minX - b.minX)
const zoneGapX = 140
let cursorX = zoneGroups[0].minX
for (const group of zoneGroups) {
const height = group.maxY - group.minY
const dy = cursorY - group.minY
if (dy !== 0) {
for (const nodeId of group.nodeIds) {
const box = layout.get(nodeId)
if (!box) continue
layout.set(nodeId, { ...box, y: box.y + dy })
for (const group of zoneGroups) {
const width = group.maxX - group.minX
const dx = cursorX - group.minX
if (dx !== 0) {
for (const nodeId of group.nodeIds) {
const box = layout.get(nodeId)
if (!box) continue
layout.set(nodeId, { ...box, x: box.x + dx })
}
}
cursorX += width + zoneGapX
}
} else {
zoneGroups.sort((a, b) => a.minY - b.minY)
const zoneGapY = 90
let cursorY = zoneGroups[0].minY
for (const group of zoneGroups) {
const height = group.maxY - group.minY
const dy = cursorY - group.minY
if (dy !== 0) {
for (const nodeId of group.nodeIds) {
const box = layout.get(nodeId)
if (!box) continue
layout.set(nodeId, { ...box, y: box.y + dy })
}
}
cursorY += height + zoneGapY
}
cursorY += height + zoneGapY
}
}
@@ -806,7 +826,7 @@ async function buildElementsFromSimplified(
const { layout, quality, rankdir, engine } = await computeNodeLayout(nodes, edges, diagramType)
if (diagramType === 'architecture-cloud' && zones.length > 1) {
separateArchitectureZones(layout, nodes, zones)
separateArchitectureZones(layout, nodes, zones, rankdir)
}
const renderSpecs = new Map<string, NodeRenderSpec>()

View File

@@ -0,0 +1,43 @@
/**
* Utilitaires de nettoyage des réponses IA
* Certains modèles (DeepSeek-R1, MiniMax, etc.) génèrent des blocs <think>
* qui corrompent le parsing JSON si non supprimés.
*/
/**
* Supprime les blocs <think>...</think> d'une réponse IA.
* Gère aussi les blocs non fermés (balise ouvrante sans fermeture).
* Supprime ensuite les fences Markdown (```json ... ```) éventuelles.
*/
export function cleanAIJsonResponse(text: string): string {
// 1. Supprimer les blocs <think>...</think> fermés (greedy-safe)
let cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim()
// 2. Si un <think> reste (non fermé), chercher le premier '[' ou '{'
if (/<think>/i.test(cleaned)) {
const jsonStart = cleaned.search(/[\[{]/)
if (jsonStart !== -1) {
cleaned = cleaned.slice(jsonStart)
} else {
// Aucun JSON trouvé après le think → vider pour provoquer une erreur propre
cleaned = ''
}
}
// 3. Supprimer les fences Markdown
cleaned = cleaned.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '').trim()
return cleaned
}
/**
* Supprime les blocs <think> d'une réponse texte libre (non-JSON).
*/
export function cleanAITextResponse(text: string): string {
let cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim()
if (/<think>/i.test(cleaned)) {
// Supprimer tout ce qui précède la fermeture du think ou jusqu'à la fin
cleaned = cleaned.replace(/<think>[\s\S]*/i, '').trim()
}
return cleaned
}

View File

@@ -31,7 +31,7 @@ export async function cancelSubscription(userId: string): Promise<CancelSubscrip
// Direct call to Stripe API with period end cancellation set to true
const updatedSub = await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
cancel_at_period_end: true,
});
}) as any;
// Local database synchronization
await prisma.subscription.update({

View File

@@ -1,8 +1,81 @@
import type { SubscriptionTier } from '@/lib/entitlements';
import { stripe } from '@/lib/stripe';
export type BillingTier = 'PRO' | 'BUSINESS';
export type BillingInterval = 'month' | 'year';
export interface DynamicPrice {
display: string;
amount: number;
currency: string;
}
export const DEFAULT_PRICES: Record<BillingTier, Record<BillingInterval, DynamicPrice>> = {
PRO: {
month: { display: '9,90 €', amount: 9.90, currency: 'EUR' },
year: { display: '99,00 €', amount: 99.00, currency: 'EUR' },
},
BUSINESS: {
month: { display: '29,90 €', amount: 29.90, currency: 'EUR' },
year: { display: '299,00 €', amount: 299.00, currency: 'EUR' },
},
};
export async function getDynamicPrices(): Promise<Record<BillingTier, Record<BillingInterval, DynamicPrice>>> {
const isMock = !process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder';
if (isMock) {
return DEFAULT_PRICES;
}
const result: Record<BillingTier, Record<BillingInterval, DynamicPrice>> = {
PRO: {
month: { ...DEFAULT_PRICES.PRO.month },
year: { ...DEFAULT_PRICES.PRO.year },
},
BUSINESS: {
month: { ...DEFAULT_PRICES.BUSINESS.month },
year: { ...DEFAULT_PRICES.BUSINESS.year },
},
};
const retrieveAndFormatPrice = async (tier: BillingTier, interval: BillingInterval) => {
try {
const priceId = resolvePriceId(tier, interval);
const price = await stripe.prices.retrieve(priceId);
if (price.unit_amount !== null && price.unit_amount !== undefined) {
const amount = price.unit_amount / 100;
const currency = price.currency.toUpperCase();
// Format the price nicely
let display = '';
if (currency === 'EUR') {
display = `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`;
} else if (currency === 'USD') {
display = `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
} else if (currency === 'GBP') {
display = `£${amount.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
} else {
display = `${amount} ${currency}`;
}
result[tier][interval] = { display, amount, currency };
}
} catch (err) {
console.error(`[stripe-prices] Failed to retrieve price for ${tier}/${interval}:`, err);
// Fallback to default in case of error
}
};
await Promise.all([
retrieveAndFormatPrice('PRO', 'month'),
retrieveAndFormatPrice('PRO', 'year'),
retrieveAndFormatPrice('BUSINESS', 'month'),
retrieveAndFormatPrice('BUSINESS', 'year'),
]);
return result;
}
export function resolvePriceId(tier: BillingTier, interval: BillingInterval): string {
const map: Record<BillingTier, Record<BillingInterval, string>> = {
PRO: {

View File

@@ -45,13 +45,13 @@ export async function syncSubscriptionFromStripe(
const status = mapStripeStatus(subscription.status);
const currentPeriodStartTimestamp =
subscription.current_period_start ??
(subscription as any).current_period_start ??
(subscription as any).items?.data?.[0]?.current_period_start ??
(subscription as any).start_date ??
Math.floor(Date.now() / 1000);
const currentPeriodEndTimestamp =
subscription.current_period_end ??
(subscription as any).current_period_end ??
(subscription as any).items?.data?.[0]?.current_period_end ??
(currentPeriodStartTimestamp + 30 * 24 * 3600);

View File

@@ -5,7 +5,17 @@ import {
type AiGatewayProvider,
} from '@/lib/ai/router';
import { getProviderConfigKeys } from '@/lib/ai/factory';
import type { SubscriptionTier } from '@/lib/entitlements';
import { getUserInfo, type SubscriptionTier } from '@/lib/entitlements';
import { redis } from '@/lib/redis';
/** Thrown when user has active BYOK configured but it can't be loaded (decryption failure, etc.). */
export class ByokUnavailableError extends Error {
readonly code = 'BYOK_UNAVAILABLE';
constructor(msg = 'Votre clé API est configurée mais n\'a pas pu être chargée.') {
super(msg);
this.name = 'ByokUnavailableError';
}
}
const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
'openai',
@@ -14,6 +24,8 @@ const PRO_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
'openrouter',
'minimax',
'zai',
'custom_openai', // Custom OpenAI-compatible API
'custom_anthropic', // Custom Anthropic-compatible API
];
const BUSINESS_BYOK_PROVIDERS: readonly AiGatewayProvider[] = [
@@ -42,33 +54,125 @@ export async function hasAnyActiveByok(userId: string): Promise<boolean> {
return count > 0;
}
export async function getActiveByokKey(userId: string, provider: string) {
return prisma.userAPIKey.findFirst({
/**
* Get active BYOK key for a user and provider.
* Optionally pass tier to avoid an extra query. If not provided, tier will be fetched.
*/
export async function getActiveByokKey(userId: string, provider: string, tier?: SubscriptionTier) {
const key = await prisma.userAPIKey.findFirst({
where: { userId, provider, isActive: true },
});
// Safety check: if key exists but provider is no longer allowed for user's tier, deactivate it
if (key) {
const effectiveTier = tier ?? (await getUserInfo(userId)).tier;
if (!isByokProviderAllowed(effectiveTier, provider)) {
await prisma.userAPIKey.update({
where: { id: key.id },
data: { isActive: false },
});
console.warn(`[byok] Deactivated key for ${provider} (user ${userId}) - tier ${effectiveTier} does not allow this provider`);
return null;
}
}
return key;
}
/**
* Deactivate all API keys that are no longer allowed for the user's current tier.
* Call this when a user's subscription tier changes.
*/
export async function deactivateUnauthorizedKeys(userId: string): Promise<number> {
const { tier } = await getUserInfo(userId);
const allowedProviders = new Set(getAllowedByokProviders(tier));
// Find all active keys
const allKeys = await prisma.userAPIKey.findMany({
where: { userId, isActive: true },
select: { id: true, provider: true },
});
// Filter keys that are no longer allowed
const toDeactivate = allKeys.filter((k) => !allowedProviders.has(k.provider as AiGatewayProvider));
if (toDeactivate.length === 0) return 0;
// Batch deactivate
await prisma.userAPIKey.updateMany({
where: {
id: { in: toDeactivate.map((k) => k.id) },
},
data: { isActive: false },
});
console.log(`[byok] Deactivated ${toDeactivate.length} keys for user ${userId} after tier change to ${tier}`);
return toDeactivate.length;
}
export async function resolveByokApiKey(
userId: string,
providerType: string,
): Promise<{ plaintext: string; provider: string; model: string | null } | null> {
feature?: string,
): Promise<{ plaintext: string; provider: string; model: string | null; baseUrl: string | null } | null> {
const row = await getActiveByokKey(userId, providerType);
if (!row) return null;
try {
const plaintext = await decryptApiKey(row.encryptedKey);
return { plaintext, provider: row.provider, model: row.model };
prisma.userAPIKey.update({
where: { id: row.id },
data: { lastUsedAt: new Date(), lastUsedFor: feature || null },
}).catch((err) => { console.error('[byok] Failed to update lastUsedAt/lastUsedFor:', err); });
return { plaintext, provider: row.provider, model: row.model, baseUrl: row.baseUrl ?? null };
} catch (err) {
console.error('[byok] Failed to decrypt key for provider', providerType, err);
return null;
}
}
/**
* Returns any active BYOK key for the user.
* Prefers a key matching preferredProvider, falls back to any active key.
* This allows BYOK to work regardless of which provider the admin configured.
*/
export async function getAnyActiveByokForUser(
userId: string,
preferredProvider?: string,
feature?: string,
): Promise<{ plaintext: string; provider: string; model: string | null; baseUrl: string | null } | null> {
// 1. Try exact match first
if (preferredProvider) {
const exact = await resolveByokApiKey(userId, preferredProvider, feature);
if (exact) return exact;
}
// 2. Fall back to any active key
const anyRow = await prisma.userAPIKey.findFirst({
where: { userId, isActive: true },
orderBy: { lastUsedAt: 'desc' },
});
if (!anyRow) return null;
try {
const plaintext = await decryptApiKey(anyRow.encryptedKey);
prisma.userAPIKey.update({
where: { id: anyRow.id },
data: { lastUsedAt: new Date(), lastUsedFor: feature || null },
}).catch(() => {});
return { plaintext, provider: anyRow.provider, model: anyRow.model, baseUrl: anyRow.baseUrl ?? null };
} catch (err) {
console.error('[byok] Failed to decrypt any active key:', err);
return null;
}
}
export async function applyByokToConfig(
billingUserId: string,
providerType: string,
config: Record<string, string>,
feature?: string,
): Promise<{ config: Record<string, string>; usedByok: boolean; model: string | null }> {
const byok = await resolveByokApiKey(billingUserId, providerType);
const byok = await resolveByokApiKey(billingUserId, providerType, feature);
if (!byok) return { config, usedByok: false, model: null };
const { apiKeyConfigKey } = getProviderConfigKeys(providerType);
@@ -87,6 +191,7 @@ export async function upsertUserApiKey(params: {
plaintext: string;
alias?: string;
model?: string;
baseUrl?: string;
}) {
const encryptedKey = await encryptApiKey(params.plaintext);
const keyHash = hashApiKey(params.plaintext);
@@ -105,6 +210,7 @@ export async function upsertUserApiKey(params: {
encryptedKey,
keyHash,
model: params.model ?? null,
baseUrl: params.baseUrl ?? null,
isActive: true,
},
update: {
@@ -112,15 +218,37 @@ export async function upsertUserApiKey(params: {
encryptedKey,
keyHash,
model: params.model ?? null,
baseUrl: params.baseUrl ?? null,
isActive: true,
},
});
}
/**
* Check if this API key hash is already used by another provider for this user.
* Returns the existing provider if found, null otherwise.
*/
export async function findDuplicateApiKeyHash(
userId: string,
keyHash: string,
excludeProvider?: string,
): Promise<string | null> {
const existing = await prisma.userAPIKey.findFirst({
where: {
userId,
keyHash,
...(excludeProvider ? { provider: { not: excludeProvider } } : {}),
},
select: { provider: true },
});
return existing?.provider ?? null;
}
export function toPublicApiKey(row: {
provider: string;
alias: string;
model: string | null;
baseUrl: string | null;
isActive: boolean;
lastUsedAt: Date | null;
createdAt: Date;
@@ -130,9 +258,77 @@ export function toPublicApiKey(row: {
provider: row.provider,
alias: row.alias,
model: row.model,
baseUrl: row.baseUrl,
isActive: row.isActive,
lastUsedAt: row.lastUsedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* Rate limit for API key creation: max 5 keys per hour per user.
* Uses atomic Lua script to prevent race conditions.
* Returns true if limit is not exceeded, false if rate limited.
*/
const RATE_LIMIT_LUA = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = tonumber(redis.call('GET', key) or '0')
if current >= limit then
local ttl = redis.call('TTL', key)
return {-1, ttl}
end
local newCount = redis.call('INCR', key)
if newCount == 1 then
redis.call('EXPIRE', key, window)
end
return {newCount, limit - newCount}
`;
// Simple in-memory cache for rate limit results (30s TTL)
const rateLimitCache = new Map<string, { result: { allowed: boolean; remaining: number; resetAt: Date | null }; expiresAt: number }>();
const CACHE_TTL = 30_000; // 30 seconds
export async function checkApiKeyCreationRateLimit(userId: string): Promise<{ allowed: boolean; remaining: number; resetAt: Date | null }> {
const key = `byok:ratelimit:create:${userId}`;
const limit = 5; // max 5 creations per hour
const window = 60 * 60; // 1 hour in seconds
// Check cache first
const cached = rateLimitCache.get(key);
if (cached && Date.now() < cached.expiresAt) {
return cached.result;
}
try {
const result = await redis.eval(RATE_LIMIT_LUA, 1, key, String(limit), String(window)) as number[];
if (!Array.isArray(result)) {
// Fallback for non-array results (shouldn't happen with correct Lua)
return { allowed: true, remaining: limit, resetAt: null };
}
const [value, ttlOrRemaining] = result;
// Rate limited
if (value === -1) {
const ttl = ttlOrRemaining as number;
const resetAt = ttl > 0 ? new Date(Date.now() + ttl * 1000) : null;
const rateLimitResult = { allowed: false, remaining: 0, resetAt };
// Cache rate limited results for 60s
rateLimitCache.set(key, { result: rateLimitResult, expiresAt: Date.now() + 60_000 });
return rateLimitResult;
}
// Allowed
const remaining = ttlOrRemaining as number;
const rateLimitResult = { allowed: true, remaining, resetAt: null };
// Don't cache allowed results too aggressively (let users create keys)
return rateLimitResult;
} catch (err) {
console.error('[byok] Rate limit check failed, allowing request:', err);
return { allowed: true, remaining: limit, resetAt: null };
}
}

View File

@@ -10,6 +10,7 @@ const OPENAI_COMPAT = new Set<string>([
'glm',
'custom',
'lmstudio',
'custom_openai',
]);
async function validateOpenAiCompatible(
@@ -25,8 +26,14 @@ async function validateOpenAiCompatible(
}
}
async function validateAnthropic(apiKey: string): Promise<void> {
const res = await fetch('https://api.anthropic.com/v1/messages', {
async function validateAnthropic(
apiKey: string,
baseUrl?: string,
): Promise<void> {
const url = baseUrl
? `${baseUrl.replace(/\/$/, '')}/messages`
: 'https://api.anthropic.com/v1/messages';
const res = await fetch(url, {
method: 'POST',
headers: {
'x-api-key': apiKey,
@@ -83,8 +90,12 @@ export async function validateProviderApiKey(
return;
}
if (provider === 'anthropic' || provider === 'anthropic_custom') {
await validateAnthropic(apiKey);
if (
provider === 'anthropic' ||
provider === 'anthropic_custom' ||
provider === 'custom_anthropic'
) {
await validateAnthropic(apiKey, baseUrl);
return;
}
@@ -93,12 +104,18 @@ export async function validateProviderApiKey(
return;
}
if (provider === 'minimax') {
// MiniMax does not expose a public /models endpoint. Key is valid if not empty.
if (!apiKey.trim()) throw new Error('API key is required');
return;
}
if (provider === 'ollama' || provider === 'lmstudio') {
throw new Error('Local providers are not supported for BYOK');
}
if (OPENAI_COMPAT.has(provider)) {
const url = provider === 'custom'
const url = (provider === 'custom' || provider === 'custom_openai')
? baseUrl
: BASE_URLS[provider as AiGatewayProvider];
if (!url) {

View File

@@ -110,3 +110,58 @@ export function openCookiePreferences(): void {
if (!isBrowser()) return
window.dispatchEvent(new CustomEvent(OPEN_COOKIE_PREFERENCES_EVENT))
}
/**
* Save consent locally and sync to database for authenticated users.
* This is the preferred method for updating consent from UI components.
*
* Note: For server-side sync, import and call saveCookieConsent action from your component.
*/
export function saveConsentWithSync(analytics: boolean, marketing: boolean = false): ConsentRecord {
const record = setConsent({ analytics, marketing })
// Trigger server-side sync in the background
// The import() is dynamic to avoid "use server" import issues in client code
if (isBrowser()) {
import('@/app/actions/cookie-consent').then(({ saveCookieConsent }) => {
saveCookieConsent(record).catch((err) => {
console.warn('[saveConsentWithSync] Server sync failed (local consent saved):', err)
})
})
}
return record
}
/**
* Load consent preferring DB value when local storage is empty.
* For authenticated users on a new device, this syncs their existing consent.
*
* Returns the consent record that should be used, or null if no preference exists.
*/
export async function loadConsentWithDBSync(): Promise<ConsentRecord | null> {
// First check local storage (fast, always available)
const local = getConsent()
if (local) {
return local // User already has local preference, use it
}
// No local preference — try to load from DB for authenticated users
if (isBrowser()) {
try {
const { getCookieConsentFromDB } = await import('@/app/actions/cookie-consent')
const dbConsent = await getCookieConsentFromDB()
if (dbConsent) {
// Sync DB value to local storage for future use
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(dbConsent))
writeCookie(dbConsent)
return dbConsent
}
} catch (error) {
console.warn('[loadConsentWithDBSync] Failed to load from DB:', error)
}
}
return null // No preference anywhere
}

View File

@@ -15,7 +15,8 @@ const TAG_LEN = 16;
function getMasterPassphrase(): string {
const master = process.env.MASTER_ENCRYPTION_KEY;
if (!master || master.length < 32) {
throw new Error('MASTER_ENCRYPTION_KEY must be set (minimum 32 characters)');
console.warn('[crypto] WARNING: MASTER_ENCRYPTION_KEY is not set or is too short. Using development fallback key.');
return 'memento-development-encryption-key-32-chars';
}
return master;
}

View File

@@ -17,7 +17,7 @@ export function resolveBlockAtDragHandle(editor: Editor): { node: PMNode; pos: n
if (coords?.pos == null) return null
const $pos = editor.state.doc.resolve(coords.pos)
const blockPos = $pos.depth > 1 ? $pos.before($pos.depth) : coords.pos
const blockPos = $pos.depth > 0 ? $pos.before($pos.depth) : coords.pos
const node = editor.state.doc.nodeAt(blockPos)
if (!node) return null

View File

@@ -75,19 +75,15 @@ const TIER_LIMITS: Record<SubscriptionTier, Record<string, number | 'unlimited'>
semantic_search: 30,
auto_tag: 15,
auto_title: 5,
chat: 10,
reformulate: 10,
brainstorm_create: 1,
brainstorm_expand: 10,
brainstorm_enrich: 20,
suggest_charts: 5,
slide_generate: 3,
excalidraw_generate: 3,
ai_flashcard: 5,
voice_transcribe: 20,
},
PRO: {
semantic_search: 100,
semantic_search: 200,
auto_tag: 500,
auto_title: 200,
reformulate: 50,

View File

@@ -15,6 +15,7 @@ export type NoteCollectionActions = {
onMoveToNotebook?: (note: Note, notebookId: string | null) => void | Promise<void>
onNotePatch?: (noteId: string, patch: Partial<Note>) => void
onNoteIllustrationGenerated?: (noteId: string) => void | Promise<void>
onNoteIllustrationDeleted?: (noteId: string) => void | Promise<void>
}
export function emitNoteChange(detail: NoteChangeEvent) {

View File

@@ -0,0 +1,150 @@
/**
* Chunking sémantique pour embeddings par fragments.
*
* Inspiré d'AppFlowy flowy-ai/src/embeddings/document_indexer.rs.
* Découpe le plain text d'une note en fragments cohérents (~1000 chars),
* avec overlap pour préserver le contexte aux frontières.
*
* Chaque fragment reçoit un fragmentId stable (sha256) pour le dedup :
* si le contenu d'un fragment ne change pas entre deux sauvegardes,
* il n'est pas re-embeddé.
*/
import { createHash } from 'crypto'
const CHUNK_TARGET_CHARS = 1000
const CHUNK_OVERLAP_CHARS = 200
const MIN_FRAGMENT_CHARS = 10
const MAX_PARAGRAPH_BEFORE_SPLIT = 1500
export interface NoteChunk {
fragmentId: string
content: string
chunkIndex: number
charCount: number
}
/**
* Découpe le plain text d'une note en fragments sémantiques.
*
* @param noteId ID de la note (inclus dans le hash pour isolation)
* @param plainText Texte brut (titre + corps), déjà nettoyé via prepareNoteTextForEmbedding
* @returns fragments triés par chunkIndex
*/
export function chunkNoteContent(noteId: string, plainText: string): NoteChunk[] {
const normalized = plainText.trim()
if (normalized.length < MIN_FRAGMENT_CHARS) return []
const paragraphs = normalized
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter((p) => p.length >= MIN_FRAGMENT_CHARS)
if (paragraphs.length === 0) return []
const atomicParagraphs: string[] = []
for (const para of paragraphs) {
if (para.length > MAX_PARAGRAPH_BEFORE_SPLIT) {
atomicParagraphs.push(...splitLongParagraph(para, CHUNK_TARGET_CHARS))
} else {
atomicParagraphs.push(para)
}
}
const groups = groupParagraphsByMaxContentLen(
atomicParagraphs,
CHUNK_TARGET_CHARS,
CHUNK_OVERLAP_CHARS,
)
const chunks: NoteChunk[] = []
const seen = new Set<string>()
for (let i = 0; i < groups.length; i++) {
const content = groups[i]
if (content.length < MIN_FRAGMENT_CHARS) continue
const fragmentId = hashFragment(noteId, content)
if (seen.has(fragmentId)) continue
seen.add(fragmentId)
chunks.push({
fragmentId,
content,
chunkIndex: i,
charCount: content.length,
})
}
return chunks
}
function hashFragment(noteId: string, content: string): string {
return createHash('sha256')
.update(`${noteId}::${content}`)
.digest('hex')
.slice(0, 32)
}
function splitLongParagraph(para: string, maxLen: number): string[] {
const sentences = para.split(/(?<=[.!?؟!。])\s+/)
const chunks: string[] = []
let current = ''
for (const sentence of sentences) {
if ((current + ' ' + sentence).length > maxLen && current) {
chunks.push(current.trim())
current = sentence
} else {
current = current ? `${current} ${sentence}` : sentence
}
}
if (current.trim()) chunks.push(current.trim())
return chunks.flatMap((chunk) =>
chunk.length > maxLen * 1.5 ? hardSplitByWords(chunk, maxLen) : [chunk],
)
}
function hardSplitByWords(text: string, maxLen: number): string[] {
const words = text.split(/\s+/)
const chunks: string[] = []
let current = ''
for (const word of words) {
if ((current + ' ' + word).length > maxLen && current) {
chunks.push(current.trim())
current = word
} else {
current = current ? `${current} ${word}` : word
}
}
if (current.trim()) chunks.push(current.trim())
return chunks
}
function groupParagraphsByMaxContentLen(
paragraphs: string[],
maxLen: number,
overlap: number,
): string[] {
if (paragraphs.length === 0) return []
if (overlap > maxLen) overlap = Math.floor(maxLen / 2)
const result: string[] = []
let current = ''
for (const para of paragraphs) {
if (current.length + para.length > maxLen && current) {
result.push(current.trim())
const tail = current.slice(-overlap)
current = `${tail}${para}`
} else {
current = current ? `${current}\n\n${para}` : para
}
}
if (current.trim()) result.push(current.trim())
return result
}

View File

@@ -60,12 +60,18 @@ export function asArray<T = unknown>(val: unknown, fallback: T[] = []): T[] {
* Guarantees array fields are always real arrays or null.
*/
export function parseNote(dbNote: any): Note {
if (!dbNote) return null as any
let noteType: NoteType = dbNote.type || 'text'
if (noteType === 'text' && dbNote.isMarkdown) {
noteType = 'markdown'
}
let content = dbNote.content
if (content && typeof content === 'string') {
content = content.replace(/(https?:\/\/(?:note\.parsanet\.org|memento-note\.com|www\.memento-note\.com|localhost:3000))?\/uploads\/notes\//gi, '/uploads/notes/')
}
return {
...dbNote,
content,
type: noteType,
isMarkdown: noteType === 'markdown',
checkItems: asArray(dbNote.checkItems, null as any) ?? null,