diff --git a/src/lib/server/db/queries/aiSettings.ts b/src/lib/server/db/queries/aiSettings.ts new file mode 100644 index 0000000..5109205 --- /dev/null +++ b/src/lib/server/db/queries/aiSettings.ts @@ -0,0 +1,91 @@ +import { db } from '../db.ts'; + +/** + * Types for ai_settings table + */ +export interface AISettings { + id: number; + enabled: number; + api_url: string; + api_key: string; + model: string; + created_at: string; + updated_at: string; +} + +export interface UpdateAISettingsInput { + enabled?: boolean; + apiUrl?: string; + apiKey?: string; + model?: string; +} + +/** + * All queries for ai_settings table + * Singleton pattern - only one settings record exists + */ +export const aiSettingsQueries = { + /** + * Get the AI settings (singleton) + */ + get(): AISettings | undefined { + return db.queryFirst('SELECT * FROM ai_settings WHERE id = 1'); + }, + + /** + * Update AI settings + */ + update(input: UpdateAISettingsInput): boolean { + const updates: string[] = []; + const params: (string | number)[] = []; + + if (input.enabled !== undefined) { + updates.push('enabled = ?'); + params.push(input.enabled ? 1 : 0); + } + if (input.apiUrl !== undefined) { + updates.push('api_url = ?'); + params.push(input.apiUrl); + } + if (input.apiKey !== undefined) { + updates.push('api_key = ?'); + params.push(input.apiKey); + } + if (input.model !== undefined) { + updates.push('model = ?'); + params.push(input.model); + } + + if (updates.length === 0) { + return false; + } + + // Add updated_at + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(1); // id is always 1 + + const affected = db.execute( + `UPDATE ai_settings SET ${updates.join(', ')} WHERE id = ?`, + ...params + ); + + return affected > 0; + }, + + /** + * Reset AI settings to defaults + */ + reset(): boolean { + const affected = db.execute(` + UPDATE ai_settings SET + enabled = 0, + api_url = 'https://api.openai.com/v1', + api_key = '', + model = 'gpt-4o-mini', + updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `); + + return affected > 0; + } +}; diff --git a/src/lib/server/utils/ai/client.ts b/src/lib/server/utils/ai/client.ts new file mode 100644 index 0000000..c331c24 --- /dev/null +++ b/src/lib/server/utils/ai/client.ts @@ -0,0 +1,147 @@ +/** + * AI client for OpenAI-compatible APIs + */ + +import { aiSettingsQueries } from '$db/queries/aiSettings.ts'; +import { logger } from '$logger/logger.ts'; + +interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +interface ChatCompletionResponse { + choices?: Array<{ + message?: { + content?: string; + }; + }>; + output?: Array<{ + type: string; + content?: Array<{ + type: string; + text?: string; + }>; + }>; +} + +/** + * Check if AI is enabled and configured + */ +export function isAIEnabled(): boolean { + const settings = aiSettingsQueries.get(); + return settings?.enabled === 1 && !!settings.api_url && !!settings.model; +} + +/** + * Generate a commit message from a diff + */ +export async function generateCommitMessage(diff: string): Promise { + const settings = aiSettingsQueries.get(); + + if (!settings || settings.enabled !== 1) { + throw new Error('AI is not enabled'); + } + + const systemPrompt = `Generate a git commit message for database operation files. + +File format: "N.operation-entity-name.sql" where operation is create/update/delete. + +Commit format: "type(entity): name" + +Types: +- create → create +- update → tweak +- delete → remove + +Entity types: custom-format, quality-profile, delay-profile, tag + +Examples: +- File "1.create-custom_format-HDR.sql" → "create(custom-format): HDR" +- File "2.update-quality_profile-HD.sql" → "tweak(quality-profile): HD" +- File "3.delete-delay_profile-test.sql" → "remove(delay-profile): test" + +For multiple files, combine: "create(custom-format): HDR, DV" or list operations. + +Output only the commit message, max 72 chars.`; + + const userPrompt = diff; + + // Use Responses API for GPT-5 models, Chat Completions for others + const isGpt5 = settings.model.startsWith('gpt-5'); + + let response: Response; + + 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' } + }) + }); + } 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 + }) + }); + } + + if (!response.ok) { + const text = await response.text(); + await logger.error('AI request failed', { + source: 'ai/client', + meta: { status: response.status, error: text } + }); + throw new Error(`AI request failed: ${response.status} ${text}`); + } + + 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'); +} diff --git a/src/lib/server/utils/git/Git.ts b/src/lib/server/utils/git/Git.ts index a62dd12..3104422 100644 --- a/src/lib/server/utils/git/Git.ts +++ b/src/lib/server/utils/git/Git.ts @@ -24,6 +24,7 @@ export class Git { checkForUpdates = (): Promise => status.checkForUpdates(this.repoPath); getLastPushed = () => status.getLastPushed(this.repoPath); getCommits = (limit?: number): Promise => status.getCommits(this.repoPath, limit); + getDiff = (filepaths?: string[]): Promise => status.getDiff(this.repoPath, filepaths); // Operation file methods getUncommittedOps = (): Promise => ops.getUncommittedOps(this.repoPath); diff --git a/src/lib/server/utils/git/status.ts b/src/lib/server/utils/git/status.ts index 1fc4876..f79a068 100644 --- a/src/lib/server/utils/git/status.ts +++ b/src/lib/server/utils/git/status.ts @@ -150,6 +150,55 @@ export async function isFileUncommitted(repoPath: string, filepath: string): Pro return status.startsWith('??') || status[0] === 'A'; } +/** + * Get diff for specific files (or all uncommitted changes if no files specified) + * Handles both tracked (modified) and untracked (new) files + */ +export async function getDiff(repoPath: string, filepaths?: string[]): Promise { + const diffs: string[] = []; + + if (filepaths && filepaths.length > 0) { + for (const filepath of filepaths) { + const relativePath = filepath.startsWith(repoPath + '/') + ? filepath.slice(repoPath.length + 1) + : filepath; + + // Check if file is untracked + const status = await execGitSafe(['status', '--porcelain', relativePath], repoPath); + const isUntracked = status?.startsWith('??'); + + if (isUntracked) { + // For untracked files, show as new file diff + try { + const content = await Deno.readTextFile(`${repoPath}/${relativePath}`); + diffs.push(`diff --git a/${relativePath} b/${relativePath} +new file mode 100644 +--- /dev/null ++++ b/${relativePath} +@@ -0,0 +1,${content.split('\n').length} @@ +${content.split('\n').map(line => '+' + line).join('\n')}`); + } catch { + // File doesn't exist or can't be read + } + } else { + // For tracked files, use git diff + const diff = await execGitSafe(['diff', 'HEAD', '--', relativePath], repoPath); + if (diff) { + diffs.push(diff); + } + } + } + } else { + // No specific files, get all changes + const diff = await execGitSafe(['diff', 'HEAD'], repoPath); + if (diff) { + diffs.push(diff); + } + } + + return diffs.join('\n\n'); +} + /** * Get commit history */ diff --git a/src/routes/api/ai/status/+server.ts b/src/routes/api/ai/status/+server.ts new file mode 100644 index 0000000..1cea1bf --- /dev/null +++ b/src/routes/api/ai/status/+server.ts @@ -0,0 +1,7 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { isAIEnabled } from '$utils/ai/client.ts'; + +export const GET: RequestHandler = async () => { + return json({ enabled: isAIEnabled() }); +}; diff --git a/src/routes/api/databases/[id]/generate-commit-message/+server.ts b/src/routes/api/databases/[id]/generate-commit-message/+server.ts new file mode 100644 index 0000000..fb64a95 --- /dev/null +++ b/src/routes/api/databases/[id]/generate-commit-message/+server.ts @@ -0,0 +1,35 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; +import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; +import { Git } from '$utils/git/index.ts'; +import { isAIEnabled, generateCommitMessage } from '$utils/ai/client.ts'; + +export const POST: RequestHandler = async ({ params, request }) => { + if (!isAIEnabled()) { + error(503, 'AI is not configured. Enable it in Settings > General.'); + } + + const id = parseInt(params.id || '', 10); + const database = databaseInstancesQueries.getById(id); + + if (!database) { + error(404, 'Database not found'); + } + + const body = await request.json(); + const files = body.files as string[] | undefined; + + const git = new Git(database.local_path); + const diff = await git.getDiff(files); + + if (!diff.trim()) { + error(400, 'No changes to generate message for'); + } + + try { + const message = await generateCommitMessage(diff); + return json({ message }); + } catch (err) { + error(500, err instanceof Error ? err.message : 'Failed to generate commit message'); + } +}; diff --git a/src/routes/databases/[id]/changes/+page.server.ts b/src/routes/databases/[id]/changes/+page.server.ts index 232ffd7..71520c9 100644 --- a/src/routes/databases/[id]/changes/+page.server.ts +++ b/src/routes/databases/[id]/changes/+page.server.ts @@ -3,6 +3,7 @@ import type { PageServerLoad, Actions } from './$types'; import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; import { Git } from '$utils/git/index.ts'; import { logger } from '$logger/logger.ts'; +import { compile, startWatch } from '$lib/server/pcd/cache.ts'; export const load: PageServerLoad = async ({ parent }) => { const { database } = await parent(); @@ -33,6 +34,19 @@ export const actions: Actions = { const git = new Git(database.local_path); await git.discardOps(files); + // Recompile cache directly instead of relying on file watcher + if (database.enabled) { + try { + await compile(database.local_path, id); + await startWatch(database.local_path, id); + } catch (err) { + await logger.error('Failed to recompile cache after discard', { + source: 'changes', + meta: { databaseId: id, error: String(err) } + }); + } + } + return { success: true }; }, diff --git a/src/routes/databases/[id]/changes/+page.svelte b/src/routes/databases/[id]/changes/+page.svelte index 6e610f8..c60ab5e 100644 --- a/src/routes/databases/[id]/changes/+page.svelte +++ b/src/routes/databases/[id]/changes/+page.svelte @@ -14,11 +14,13 @@ let uncommittedOps: OperationFile[] = []; let branches: string[] = []; let repoInfo: RepoInfo | null = null; + let aiEnabled = false; let selected = new Set(); let commitMessage = ''; $: allSelected = uncommittedOps.length > 0 && selected.size === uncommittedOps.length; + $: selectedFiles = Array.from(selected); async function fetchChanges() { loading = true; @@ -36,8 +38,21 @@ } } + async function checkAiStatus() { + try { + const response = await fetch('/api/ai/status'); + if (response.ok) { + const result = await response.json(); + aiEnabled = result.enabled; + } + } catch { + aiEnabled = false; + } + } + afterNavigate(() => { fetchChanges(); + checkAiStatus(); }); function toggleAll() { @@ -72,10 +87,15 @@ const result = await response.json(); - if (result.type === 'success' && result.data?.success) { + // SvelteKit form action response types: 'success', 'redirect', 'failure', 'error' + // Redirect is also considered success for our purposes + const isSuccess = result.type === 'success' || result.type === 'redirect' || result.data?.success; + const errorMsg = result.data?.error || result.error; + + if (isSuccess && !errorMsg) { alertStore.add('success', 'Changes discarded'); } else { - alertStore.add('error', result.data?.error || 'Failed to discard changes'); + alertStore.add('error', errorMsg || 'Failed to discard changes'); } selected = new Set(); @@ -97,10 +117,14 @@ const result = await response.json(); - if (result.type === 'success' && result.data?.success) { + // SvelteKit form action response types: 'success', 'redirect', 'failure', 'error' + const isSuccess = result.type === 'success' || result.type === 'redirect' || result.data?.success; + const errorMsg = result.data?.error || result.error; + + if (isSuccess && !errorMsg) { alertStore.add('success', 'Changes committed and pushed'); } else { - alertStore.add('error', result.data?.error || 'Failed to add changes'); + alertStore.add('error', errorMsg || 'Failed to add changes'); } // Always clear and refresh @@ -166,8 +190,11 @@ {:else} diff --git a/src/routes/databases/[id]/changes/components/ChangesActionsBar.svelte b/src/routes/databases/[id]/changes/components/ChangesActionsBar.svelte index 2f76a87..3bb40b7 100644 --- a/src/routes/databases/[id]/changes/components/ChangesActionsBar.svelte +++ b/src/routes/databases/[id]/changes/components/ChangesActionsBar.svelte @@ -1,17 +1,49 @@ @@ -28,6 +60,30 @@ + {#if aiEnabled} + + + +
+ {#if generating} + Generating... + {:else if !selectedCount} + Select changes first + {:else} + Generate commit message + {/if} +
+
+
+
+ {/if} + { const logSetting = logSettingsQueries.get(); const backupSetting = backupSettingsQueries.get(); + const aiSetting = aiSettingsQueries.get(); if (!logSetting) { throw new Error('Log settings not found in database'); @@ -17,6 +19,10 @@ export const load = () => { throw new Error('Backup settings not found in database'); } + if (!aiSetting) { + throw new Error('AI settings not found in database'); + } + return { logSettings: { retention_days: logSetting.retention_days, @@ -31,6 +37,12 @@ export const load = () => { enabled: backupSetting.enabled === 1, include_database: backupSetting.include_database === 1, compression_enabled: backupSetting.compression_enabled === 1 + }, + aiSettings: { + enabled: aiSetting.enabled === 1, + api_url: aiSetting.api_url, + api_key: aiSetting.api_key, + model: aiSetting.model } }; }; @@ -151,6 +163,52 @@ export const actions: Actions = { } }); + return { success: true }; + }, + + updateAI: async ({ request }: RequestEvent) => { + const formData = await request.formData(); + + // Parse form data + const enabled = formData.get('enabled') === 'on'; + const apiUrl = formData.get('api_url') as string; + const apiKey = formData.get('api_key') as string; + const model = formData.get('model') as string; + + // Validate + if (enabled && !apiUrl) { + return fail(400, { error: 'API URL is required when AI is enabled' }); + } + + if (enabled && !model) { + return fail(400, { error: 'Model is required when AI is enabled' }); + } + + // Update settings + const updated = aiSettingsQueries.update({ + enabled, + apiUrl: apiUrl || 'https://api.openai.com/v1', + apiKey: apiKey || '', + model: model || 'gpt-4o-mini' + }); + + if (!updated) { + await logger.error('Failed to update AI settings', { + source: 'settings/general' + }); + return fail(500, { error: 'Failed to update settings' }); + } + + await logger.info('AI settings updated', { + source: 'settings/general', + meta: { + enabled, + apiUrl, + model + // Note: Don't log apiKey for security + } + }); + return { success: true }; } }; diff --git a/src/routes/settings/general/+page.svelte b/src/routes/settings/general/+page.svelte index 0ce2ec7..3f96097 100644 --- a/src/routes/settings/general/+page.svelte +++ b/src/routes/settings/general/+page.svelte @@ -1,6 +1,7 @@ + +
+ +
+

+ AI Configuration +

+

+ Configure AI-powered features like commit message generation. Works with OpenAI, Ollama, LM Studio, or any OpenAI-compatible API. +

+
+ + +
{ + return async ({ result, update }) => { + if (result.type === 'failure' && result.data) { + alertStore.add('error', (result.data as { error?: string }).error || 'Failed to save'); + } else if (result.type === 'success') { + alertStore.add('success', 'AI settings saved successfully!'); + } + await update(); + }; + }} + > +
+ +
+ +
+ + {#if settings.enabled} + +
+ + +
+ + +

+ OpenAI-compatible endpoint. Examples: Ollama (http://localhost:11434/v1), LM Studio (http://localhost:1234/v1) +

+
+ + +
+ +
+ + +
+

+ Required for cloud providers. Leave empty for local APIs like Ollama. +

+
+ + +
+ + +

+ Model name. Examples: gpt-4o-mini, llama3.2, claude-3-haiku +

+
+ {/if} +
+ + +
+ + + +
+
+
diff --git a/src/routes/settings/general/components/types.ts b/src/routes/settings/general/components/types.ts index 778f34d..222aff0 100644 --- a/src/routes/settings/general/components/types.ts +++ b/src/routes/settings/general/components/types.ts @@ -17,3 +17,10 @@ export interface BackupSettings { include_database: boolean; compression_enabled: boolean; } + +export interface AISettings { + enabled: boolean; + api_url: string; + api_key: string; + model: string; +}