perf: memo GridCard, fuse save fns, fix slash tab active color
This commit is contained in:
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
217
memento-note/lib/ai/services/chunk-indexing.service.ts
Normal file
217
memento-note/lib/ai/services/chunk-indexing.service.ts
Normal 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()
|
||||
@@ -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).
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>()
|
||||
|
||||
43
memento-note/lib/ai/utils/clean-ai-response.ts
Normal file
43
memento-note/lib/ai/utils/clean-ai-response.ts
Normal 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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
150
memento-note/lib/text/note-chunking.ts
Normal file
150
memento-note/lib/text/note-chunking.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user