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
}