feat: icon-only toolbar, versioning fixes, history modal, PanelRight repositioning
- Toolbar: remove text labels from all icon buttons (AI, Save, Preview, Convert) all buttons now icon-only with title tooltip for accessibility - Toolbar: reposition PanelRight (info panel toggle) to far right after three-dot menu - Versioning: decouple getNoteHistory/restoreNoteVersion from global userAISettings.noteHistory now checks note.historyEnabled directly — unblocks manual per-note history - Versioning: add 'Sauvegarder cette version' button in Versions tab of info panel calls commitNoteHistory with visual feedback (spinner → success state) - note-document-info-panel: import commitNoteHistory, add isSavingVersion state - notes.ts: fix double guard that silently blocked all history operations
This commit is contained in:
@@ -1,9 +1,20 @@
|
||||
import { OpenAIProvider } from './providers/openai';
|
||||
import { OllamaProvider } from './providers/ollama';
|
||||
import { CustomOpenAIProvider } from './providers/custom-openai';
|
||||
import { AnthropicProvider } from './providers/anthropic';
|
||||
import { AIProvider } from './types';
|
||||
|
||||
type ProviderType = 'ollama' | 'openai' | 'custom' | 'deepseek' | 'openrouter' | 'mistral' | 'zai' | 'lmstudio';
|
||||
type ProviderType =
|
||||
| 'ollama'
|
||||
| 'openai'
|
||||
| 'custom'
|
||||
| 'deepseek'
|
||||
| 'openrouter'
|
||||
| 'mistral'
|
||||
| 'zai'
|
||||
| 'lmstudio'
|
||||
| 'anthropic'
|
||||
| 'anthropic_custom';
|
||||
|
||||
// --- Provider defaults ---
|
||||
const PROVIDER_DEFAULTS: Record<string, { baseUrl: string; model: string; embeddingModel: string }> = {
|
||||
@@ -115,6 +126,36 @@ function createLMStudioProvider(config: Record<string, string>, modelName: strin
|
||||
return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName);
|
||||
}
|
||||
|
||||
function createAnthropicProvider(config: Record<string, string>, modelName: string): AnthropicProvider {
|
||||
const apiKey = config?.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY || '';
|
||||
if (!apiKey) {
|
||||
throw new Error('ANTHROPIC_API_KEY is required when using Anthropic provider');
|
||||
}
|
||||
return new AnthropicProvider(apiKey, modelName || 'claude-sonnet-4-20250514');
|
||||
}
|
||||
|
||||
/**
|
||||
* Passerelles compatibles **Anthropic Messages API** (ex. MiniMax), pas OpenAI.
|
||||
* Le SDK envoie les requêtes vers `{baseURL}/messages` avec l’en-tête `x-api-key`.
|
||||
*/
|
||||
function createAnthropicCustomProvider(config: Record<string, string>, modelName: string): AnthropicProvider {
|
||||
const apiKey = config?.ANTHROPIC_CUSTOM_API_KEY || process.env.ANTHROPIC_CUSTOM_API_KEY || '';
|
||||
const baseUrl = config?.ANTHROPIC_CUSTOM_BASE_URL || process.env.ANTHROPIC_CUSTOM_BASE_URL || '';
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('ANTHROPIC_CUSTOM_API_KEY is required when using Anthropic Custom provider');
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error('ANTHROPIC_CUSTOM_BASE_URL is required when using Anthropic Custom provider');
|
||||
}
|
||||
|
||||
const resolvedModel =
|
||||
modelName && modelName.trim() !== '' ? modelName.trim() : 'MiniMax-M2.7';
|
||||
|
||||
return new AnthropicProvider(apiKey, resolvedModel, baseUrl.trim());
|
||||
}
|
||||
|
||||
function getProviderInstance(providerType: ProviderType, config: Record<string, string>, modelName: string, embeddingModelName: string, ollamaBaseUrl?: string): AIProvider {
|
||||
switch (providerType) {
|
||||
case 'ollama':
|
||||
@@ -133,6 +174,10 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
|
||||
return createZAIProvider(config, modelName, embeddingModelName);
|
||||
case 'lmstudio':
|
||||
return createLMStudioProvider(config, modelName, embeddingModelName);
|
||||
case 'anthropic':
|
||||
return createAnthropicProvider(config, modelName);
|
||||
case 'anthropic_custom':
|
||||
return createAnthropicCustomProvider(config, modelName);
|
||||
default:
|
||||
return createOllamaProvider(config, modelName, embeddingModelName, ollamaBaseUrl);
|
||||
}
|
||||
@@ -148,6 +193,9 @@ function getProviderConfigKeys(providerType: string): { apiKeyConfigKey: string;
|
||||
case 'zai': return { apiKeyConfigKey: 'ZAI_API_KEY', baseUrlConfigKey: '' };
|
||||
case 'lmstudio': return { apiKeyConfigKey: 'LMSTUDIO_API_KEY', baseUrlConfigKey: 'LMSTUDIO_BASE_URL' };
|
||||
case 'openai': return { apiKeyConfigKey: 'OPENAI_API_KEY', baseUrlConfigKey: '' };
|
||||
case 'anthropic': return { apiKeyConfigKey: 'ANTHROPIC_API_KEY', baseUrlConfigKey: '' };
|
||||
case 'anthropic_custom':
|
||||
return { apiKeyConfigKey: 'ANTHROPIC_CUSTOM_API_KEY', baseUrlConfigKey: 'ANTHROPIC_CUSTOM_BASE_URL' };
|
||||
case 'custom': return { apiKeyConfigKey: 'CUSTOM_OPENAI_API_KEY', baseUrlConfigKey: 'CUSTOM_OPENAI_BASE_URL' };
|
||||
default: return { apiKeyConfigKey: '', baseUrlConfigKey: 'OLLAMA_BASE_URL' };
|
||||
}
|
||||
@@ -167,7 +215,7 @@ export function getTagsProvider(config?: Record<string, string>): AIProvider {
|
||||
console.error('[getTagsProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_TAGS is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -198,6 +246,12 @@ export function getEmbeddingsProvider(config?: Record<string, string>): AIProvid
|
||||
}
|
||||
|
||||
const provider = providerType.toLowerCase() as ProviderType;
|
||||
|
||||
if (provider === 'anthropic' || provider === 'anthropic_custom') {
|
||||
throw new Error(
|
||||
'AI_PROVIDER_EMBEDDING cannot use "anthropic" or "anthropic_custom": these gateways use the Anthropic Messages API only (no embeddings in Memento). Use ollama, openai, or "custom" with MiniMax OpenAI URL https://api.minimax.io/v1 for embeddings.'
|
||||
);
|
||||
}
|
||||
const modelName = config?.AI_MODEL_TAGS || process.env.AI_MODEL_TAGS || 'granite4:latest';
|
||||
const embeddingModelName = config?.AI_MODEL_EMBEDDING || process.env.AI_MODEL_EMBEDDING || 'embeddinggemma:latest';
|
||||
const ollamaBaseUrl = config?.OLLAMA_BASE_URL_EMBEDDING || config?.OLLAMA_BASE_URL;
|
||||
@@ -225,7 +279,7 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
|
||||
console.error('[getChatProvider] FATAL: No provider configured. Config received:', config);
|
||||
throw new Error(
|
||||
'AI_PROVIDER_CHAT is not configured. Please set it in the admin settings or environment variables. ' +
|
||||
'Options: ollama, openai, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
'Options: ollama, openai, anthropic, anthropic_custom, deepseek, openrouter, mistral, zai, lmstudio, custom'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
122
memento-note/lib/ai/providers/anthropic.ts
Normal file
122
memento-note/lib/ai/providers/anthropic.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { generateObject, generateText as aiGenerateText, stepCountIs } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion, ToolUseOptions, ToolCallResult } from '../types';
|
||||
|
||||
export class AnthropicProvider implements AIProvider {
|
||||
private model: any;
|
||||
|
||||
/**
|
||||
* @param baseURL Optional Messages API root (no trailing slash). The SDK calls `{baseURL}/messages`.
|
||||
* MiniMax: `https://api.minimax.io/anthropic` (China: `https://api.minimaxi.com/anthropic`).
|
||||
*/
|
||||
constructor(apiKey: string, modelName: string = 'claude-sonnet-4-20250514', baseURL?: string) {
|
||||
const trimmedBase = baseURL?.trim().replace(/\/+$/, '');
|
||||
const anthropicClient = createAnthropic(trimmedBase ? { apiKey, baseURL: trimmedBase } : { apiKey });
|
||||
this.model = anthropicClient.chat(modelName);
|
||||
}
|
||||
|
||||
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}"`,
|
||||
});
|
||||
|
||||
return object.tags;
|
||||
} catch (e) {
|
||||
console.error('Error generating tags (Anthropic):', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getEmbeddings(_text: string): Promise<number[]> {
|
||||
throw new Error(
|
||||
'Anthropic does not expose embedding models in Memento. Choose another provider for embeddings (e.g. Ollama or OpenAI).'
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return object.titles;
|
||||
} catch (e) {
|
||||
console.error('Error generating titles (Anthropic):', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
prompt,
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
} catch (e) {
|
||||
console.error('Error generating text (Anthropic):', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(messages: any[], systemPrompt?: string): Promise<any> {
|
||||
try {
|
||||
const { text } = await aiGenerateText({
|
||||
model: this.model,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
});
|
||||
|
||||
return { text: text.trim() };
|
||||
} catch (e) {
|
||||
console.error('Error in chat (Anthropic):', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async generateWithTools(options: ToolUseOptions): Promise<ToolCallResult> {
|
||||
const { tools, maxSteps = 10, systemPrompt, messages, prompt } = options;
|
||||
const opts: Record<string, any> = {
|
||||
model: this.model,
|
||||
tools,
|
||||
stopWhen: stepCountIs(maxSteps),
|
||||
};
|
||||
if (systemPrompt) opts.system = systemPrompt;
|
||||
if (messages) opts.messages = messages;
|
||||
else if (prompt) opts.prompt = prompt;
|
||||
|
||||
const result = await aiGenerateText(opts as any);
|
||||
return {
|
||||
toolCalls: result.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: result.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
|
||||
text: result.text,
|
||||
steps: result.steps?.map((step: any) => ({
|
||||
text: step.text,
|
||||
toolCalls: step.toolCalls?.map((tc: any) => ({ toolName: tc.toolName, input: tc.input })) || [],
|
||||
toolResults: step.toolResults?.map((tr: any) => ({ toolName: tr.toolName, input: tr.input, output: tr.output })) || [],
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
getModel() {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
@@ -83,21 +83,23 @@ export class CustomOpenAIProvider implements AIProvider {
|
||||
|
||||
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
// Use generateText instead of generateObject — DeepSeek doesn't support
|
||||
// response_format: json_schema via the OpenAI compat layer
|
||||
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;
|
||||
// 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 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 (Custom OpenAI):', e);
|
||||
return [];
|
||||
console.error('Error generating titles (Custom OpenAI):', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,4 +67,14 @@ export interface AIProvider {
|
||||
generateWithTools(options: ToolUseOptions): Promise<ToolCallResult>;
|
||||
}
|
||||
|
||||
export type AIProviderType = 'openai' | 'ollama' | 'custom' | 'deepseek' | 'openrouter';
|
||||
export type AIProviderType =
|
||||
| 'openai'
|
||||
| 'ollama'
|
||||
| 'custom'
|
||||
| 'deepseek'
|
||||
| 'openrouter'
|
||||
| 'mistral'
|
||||
| 'zai'
|
||||
| 'lmstudio'
|
||||
| 'anthropic'
|
||||
| 'anthropic_custom';
|
||||
|
||||
Reference in New Issue
Block a user