@notes-sync/service
Version:
Background service for AI-powered note synchronization
196 lines (167 loc) • 5.82 kB
text/typescript
import {
AIProvider,
GenerateQuoteRequest,
GenerateQuoteResponse,
GenerateQueryRequest,
GenerateQueryResponse,
AIError,
AIRateLimitError,
AIConfig,
} from '@notes-sync/shared';
import { GoogleGenAI } from '@google/genai';
import { Logger } from '../../logger';
export class GeminiProvider implements AIProvider {
public readonly name = 'gemini';
private config: AIConfig['features']['dailyQuotes'];
private ai: GoogleGenAI;
constructor(
private apiKey: string,
private model: string = 'gemini-2.5-flash-lite',
quoteConfig: AIConfig['features']['dailyQuotes']
) {
if (!apiKey) {
throw new Error('Gemini API key is required');
}
// Initialize the Google GenAI client
this.ai = new GoogleGenAI({ apiKey: this.apiKey });
// Set defaults for quote configuration
this.config = {
maxLength: quoteConfig?.maxLength || 30,
focus: quoteConfig?.focus || ['productivity', 'personal growth'],
adjectives: quoteConfig?.adjectives || ['historical', 'motivational'],
additionalRules: quoteConfig?.additionalRules || [
'If quoting from spiritual texts, include the book name as author',
'Prefer wisdom that applies to daily work and life',
],
};
}
async generateQuote(
request: GenerateQuoteRequest
): Promise<GenerateQuoteResponse> {
try {
const prompt = this.buildQuotePrompt(request);
const response = await this.ai.models.generateContent({
model: this.model,
contents: prompt,
config: {
temperature: 0.8,
topK: 20,
topP: 0.8,
maxOutputTokens: 150,
},
});
if (!response.text) {
throw new AIError('No text content in Gemini API response', this.name);
}
const parsed = this.parseQuoteResponse(response.text);
Logger.log(
`Generated quote via Gemini: "${parsed.quote}" - ${parsed.author}`
);
return parsed;
} catch (error) {
// Handle rate limiting errors
if (error instanceof Error && error.message.includes('429')) {
throw new AIRateLimitError(this.name);
}
if (error instanceof AIError) {
throw error;
}
Logger.error(
`Gemini quote generation failed: ${(error as Error).message}`
);
throw new AIError(
`Failed to generate quote: ${(error as Error).message}`,
this.name
);
}
}
private buildQuotePrompt(request: GenerateQuoteRequest): string {
if (!this.config) {
return 'Find a short, daily quote for someone focused on productivity in the format -> "Quote text" - Author Name -> Under 50 characters.';
}
const theme = request.theme || 'productivity';
const context = request.context
? `\n\nContext about the user's recent work: ${request.context}`
: '';
const additionalRulesText = this.config.additionalRules?.length
? `\n- ${this.config.additionalRules.join('\n- ')}`
: '';
return `Generate a short, daily quote for someone focused on ${this.config.focus?.join(' and ')}.
Theme: ${theme}
${context}
Requirements:
- Keep it under ${this.config.maxLength} words
- Make it ${this.config.adjectives?.join(' and ')}
- Include the author's name or passage if from spiritual book of unknown author
- Format as: "Quote text" - Author Name
- Avoid cliches and focus on practical wisdom${additionalRulesText}
Examples:
"The man who lies to himself and listens to his own lie comes to a point that he cannot distinguish the truth within him, or around him, and so loses all respect for himself and for others." - Fyodor Dostoyevsky
"Pain is inevitable. Suffering is optional. The difference lies in what we choose to do with our pain." - Haruki Murakami
"Your future self is counting on what you do today" - Unknown
Find a real quote ${this.config.allowGenerated && ' or generate one.'}`;
}
async processQuery(
request: GenerateQueryRequest
): Promise<GenerateQueryResponse> {
try {
const response = await this.ai.models.generateContent({
model: this.model,
contents: request.query,
config: {
temperature: 0.7,
topK: 40,
topP: 0.9,
maxOutputTokens: request.maxLength || 500,
},
});
if (!response.text) {
throw new AIError('No text content in Gemini API response', this.name);
}
Logger.log(`Processed AI query: ${request.query.substring(0, 50)}...`);
return {
response: response.text.trim(),
};
} catch (error) {
// Handle rate limiting errors
if (error instanceof Error && error.message.includes('429')) {
throw new AIRateLimitError(this.name);
}
if (error instanceof AIError) {
throw error;
}
Logger.error(
`Gemini query processing failed: ${(error as Error).message}`
);
throw new AIError(
`Failed to process query: ${(error as Error).message}`,
this.name
);
}
}
private parseQuoteResponse(text: string): GenerateQuoteResponse {
// Try to parse quote in format: "Quote text" - Author
const quoteMatch = text.match(/[""]([^"""]+)[""] - (.+)/);
if (quoteMatch) {
return {
quote: quoteMatch[1].trim(),
author: quoteMatch[2].trim(),
};
}
// Fallback: try to find any quoted text
const fallbackMatch =
text.match(/[""]([^"""]+)[""]/) || text.match(/"([^"]+)"/);
if (fallbackMatch) {
return {
quote: fallbackMatch[1].trim(),
author: 'Unknown',
};
}
// Last resort: use the first line and attribute to Unknown
const firstLine = text.split('\n')[0].trim();
return {
quote: firstLine,
author: 'Unknown',
};
}
}