refactor: use base http client for notifications, parser, autocomp

This commit is contained in:
Sam Chau
2026-01-19 02:26:12 +10:30
parent f6d99bc267
commit 8d3e20d3c3
5 changed files with 232 additions and 180 deletions

View File

@@ -4,6 +4,7 @@
import { aiSettingsQueries } from '$db/queries/aiSettings.ts';
import { logger } from '$logger/logger.ts';
import { BaseHttpClient } from '../http/client.ts';
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
@@ -25,6 +26,72 @@ interface ChatCompletionResponse {
}>;
}
/**
* AI API HTTP client
* Extends BaseHttpClient for OpenAI-compatible APIs
*/
class AIClient extends BaseHttpClient {
constructor(baseUrl: string, apiKey?: string) {
super(baseUrl, {
timeout: 60000, // AI requests can be slow
retries: 2,
retryDelay: 1000,
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
});
}
/**
* Chat completions API (GPT-4, etc.)
*/
chatCompletions(model: string, messages: ChatMessage[], maxTokens = 100, temperature = 0.3): Promise<ChatCompletionResponse> {
return this.post<ChatCompletionResponse>('/chat/completions', {
model,
messages,
max_tokens: maxTokens,
temperature
});
}
/**
* Responses API (GPT-5)
*/
responses(model: string, instructions: string, input: string): Promise<ChatCompletionResponse> {
return this.post<ChatCompletionResponse>('/responses', {
model,
instructions,
input,
text: { verbosity: 'low' }
});
}
}
// Cached client instance (recreated if settings change)
let cachedClient: AIClient | null = null;
let cachedApiUrl: string | null = null;
let cachedApiKey: string | null = null;
function getClient(): AIClient | null {
const settings = aiSettingsQueries.get();
if (!settings || settings.enabled !== 1 || !settings.api_url) {
return null;
}
// Recreate client if settings changed
if (!cachedClient || cachedApiUrl !== settings.api_url || cachedApiKey !== settings.api_key) {
if (cachedClient) {
cachedClient.close();
}
cachedClient = new AIClient(settings.api_url, settings.api_key || undefined);
cachedApiUrl = settings.api_url;
cachedApiKey = settings.api_key;
}
return cachedClient;
}
/**
* Check if AI is enabled and configured
*/
@@ -43,6 +110,11 @@ export async function generateCommitMessage(diff: string): Promise<string> {
throw new Error('AI is not enabled');
}
const client = getClient();
if (!client) {
throw new Error('AI client not available');
}
const systemPrompt = `Generate a git commit message for database operation files.
File format: "N.operation-entity-name.sql" where operation is create/update/delete.
@@ -65,83 +137,51 @@ For multiple files, combine: "create(custom-format): HDR, DV" or list operations
Output only the commit message, max 72 chars.`;
const userPrompt = diff;
try {
let data: ChatCompletionResponse;
// Use Responses API for GPT-5 models, Chat Completions for others
const isGpt5 = settings.model.startsWith('gpt-5');
// Use Responses API for GPT-5 models, Chat Completions for others
const isGpt5 = settings.model.startsWith('gpt-5');
let response: Response;
if (isGpt5) {
data = await client.responses(settings.model, systemPrompt, diff);
} else {
const messages: ChatMessage[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: diff }
];
data = await client.chatCompletions(settings.model, messages);
}
if (isGpt5) {
// Responses API (recommended for GPT-5)
response = await fetch(`${settings.api_url}/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(settings.api_key ? { 'Authorization': `Bearer ${settings.api_key}` } : {})
},
body: JSON.stringify({
model: settings.model,
instructions: systemPrompt,
input: userPrompt,
text: { verbosity: 'low' }
})
await logger.debug('AI response received', {
source: 'ai/client',
meta: { response: JSON.stringify(data) }
});
} else {
// Chat Completions API (for other models)
const messages: ChatMessage[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
];
response = await fetch(`${settings.api_url}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(settings.api_key ? { 'Authorization': `Bearer ${settings.api_key}` } : {})
},
body: JSON.stringify({
model: settings.model,
messages,
max_tokens: 100,
temperature: 0.3
})
// Handle Responses API format
if (data.output) {
const textOutput = data.output.find(o => o.type === 'message');
const textContent = textOutput?.content?.find(c => c.type === 'output_text');
if (textContent?.text) {
return textContent.text.trim();
}
}
// Handle Chat Completions API format
if (data.choices?.[0]?.message?.content) {
return data.choices[0].message.content.trim();
}
await logger.error('Invalid AI response structure', {
source: 'ai/client',
meta: { response: JSON.stringify(data) }
});
}
if (!response.ok) {
const text = await response.text();
throw new Error('Invalid response from AI');
} catch (error) {
await logger.error('AI request failed', {
source: 'ai/client',
meta: { status: response.status, error: text }
meta: { error: error instanceof Error ? error.message : String(error) }
});
throw new Error(`AI request failed: ${response.status} ${text}`);
throw error;
}
const data = await response.json() as ChatCompletionResponse;
await logger.debug('AI response received', {
source: 'ai/client',
meta: { response: JSON.stringify(data) }
});
// Handle Responses API format
if (data.output) {
const textOutput = data.output.find(o => o.type === 'message');
const textContent = textOutput?.content?.find(c => c.type === 'output_text');
if (textContent?.text) {
return textContent.text.trim();
}
}
// Handle Chat Completions API format
if (data.choices?.[0]?.message?.content) {
return data.choices[0].message.content.trim();
}
await logger.error('Invalid AI response structure', {
source: 'ai/client',
meta: { response: JSON.stringify(data) }
});
throw new Error('Invalid response from AI');
}

View File

@@ -5,6 +5,7 @@
import { config } from '$config';
import { logger } from '$logger/logger.ts';
import { BaseHttpClient } from '../../http/client.ts';
import { parsedReleaseCacheQueries } from '$db/queries/parsedReleaseCache.ts';
import { patternMatchCacheQueries } from '$db/queries/patternMatchCache.ts';
import {
@@ -14,7 +15,6 @@ import {
ReleaseType,
type QualityInfo,
type ParseResult,
type EpisodeInfo,
type Resolution,
type MediaType
} from './types.ts';
@@ -59,23 +59,78 @@ interface ParseResponse {
episode: EpisodeResponse | null;
}
interface HealthResponse {
status: string;
version: string;
}
interface MatchResponse {
results: Record<string, boolean>;
}
interface BatchMatchResponse {
results: Record<string, Record<string, boolean>>;
}
/**
* Parser service HTTP client
* Extends BaseHttpClient with parser-specific methods
*/
class ParserClient extends BaseHttpClient {
constructor(baseUrl: string) {
super(baseUrl, {
timeout: 30000,
retries: 2,
retryDelay: 500
});
}
/**
* Parse a release title
*/
async parse(title: string, type: MediaType): Promise<ParseResponse> {
return this.post<ParseResponse>('/parse', { title, type });
}
/**
* Check health and get version
*/
async health(): Promise<HealthResponse> {
return this.get<HealthResponse>('/health');
}
/**
* Match patterns against text
*/
async match(text: string, patterns: string[]): Promise<MatchResponse> {
return this.post<MatchResponse>('/match', { text, patterns });
}
/**
* Match patterns against multiple texts (batch)
*/
async matchBatch(texts: string[], patterns: string[]): Promise<BatchMatchResponse> {
return this.post<BatchMatchResponse>('/match/batch', { texts, patterns });
}
}
// Singleton client instance - lazy initialized
let parserClient: ParserClient | null = null;
function getClient(): ParserClient {
if (!parserClient) {
parserClient = new ParserClient(config.parserUrl);
}
return parserClient;
}
/**
* Parse a release title - returns quality, resolution, modifier, revision, and languages
* @param title - The release title to parse
* @param type - The media type: 'movie' or 'series'
*/
export async function parse(title: string, type: MediaType): Promise<ParseResult> {
const res = await fetch(`${config.parserUrl}/parse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, type })
});
if (!res.ok) {
throw new Error(`Parser error: ${res.status}`);
}
const data: ParseResponse = await res.json();
const data = await getClient().parse(title, type);
return {
title: data.title,
@@ -134,8 +189,8 @@ export async function parseQuality(title: string, type: MediaType): Promise<Qual
*/
export async function isParserHealthy(): Promise<boolean> {
try {
const res = await fetch(`${config.parserUrl}/health`);
return res.ok;
await getClient().health();
return true;
} catch {
return false;
}
@@ -151,16 +206,7 @@ export async function getParserVersion(): Promise<string | null> {
}
try {
const res = await fetch(`${config.parserUrl}/health`);
if (!res.ok) {
await logger.warn('Parser health check failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { status: string; version: string } = await res.json();
const data = await getClient().health();
cachedParserVersion = data.version;
await logger.debug(`Parser version: ${data.version}`, { source: 'ParserClient' });
return cachedParserVersion;
@@ -333,21 +379,7 @@ export async function matchPatterns(
}
try {
const res = await fetch(`${config.parserUrl}/match`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, patterns })
});
if (!res.ok) {
await logger.warn('Pattern match request failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { results: Record<string, boolean> } = await res.json();
const data = await getClient().match(text, patterns);
return new Map(Object.entries(data.results));
} catch (err) {
await logger.warn('Failed to connect to parser for pattern matching', {
@@ -378,21 +410,7 @@ async function fetchPatternMatches(
patterns: string[]
): Promise<Map<string, Map<string, boolean>> | null> {
try {
const res = await fetch(`${config.parserUrl}/match/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ texts, patterns })
});
if (!res.ok) {
await logger.warn('Batch pattern match request failed', {
source: 'ParserClient',
meta: { status: res.status }
});
return null;
}
const data: { results: Record<string, Record<string, boolean>> } = await res.json();
const data = await getClient().matchBatch(texts, patterns);
const result = new Map<string, Map<string, boolean>>();
for (const [text, patternResults] of Object.entries(data.results)) {