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
|
||||
}
|
||||
Reference in New Issue
Block a user