mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-24 03:31:12 +01:00
feat(ai): implement AI settings management and commit message generation
This commit is contained in:
91
src/lib/server/db/queries/aiSettings.ts
Normal file
91
src/lib/server/db/queries/aiSettings.ts
Normal file
@@ -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<AISettings>('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;
|
||||
}
|
||||
};
|
||||
147
src/lib/server/utils/ai/client.ts
Normal file
147
src/lib/server/utils/ai/client.ts
Normal file
@@ -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<string> {
|
||||
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');
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export class Git {
|
||||
checkForUpdates = (): Promise<UpdateInfo> => status.checkForUpdates(this.repoPath);
|
||||
getLastPushed = () => status.getLastPushed(this.repoPath);
|
||||
getCommits = (limit?: number): Promise<Commit[]> => status.getCommits(this.repoPath, limit);
|
||||
getDiff = (filepaths?: string[]): Promise<string> => status.getDiff(this.repoPath, filepaths);
|
||||
|
||||
// Operation file methods
|
||||
getUncommittedOps = (): Promise<OperationFile[]> => ops.getUncommittedOps(this.repoPath);
|
||||
|
||||
@@ -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<string> {
|
||||
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
|
||||
*/
|
||||
|
||||
7
src/routes/api/ai/status/+server.ts
Normal file
7
src/routes/api/ai/status/+server.ts
Normal file
@@ -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() });
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
let uncommittedOps: OperationFile[] = [];
|
||||
let branches: string[] = [];
|
||||
let repoInfo: RepoInfo | null = null;
|
||||
let aiEnabled = false;
|
||||
|
||||
let selected = new Set<string>();
|
||||
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 @@
|
||||
</div>
|
||||
{:else}
|
||||
<ChangesActionsBar
|
||||
databaseId={data.database.id}
|
||||
selectedCount={selected.size}
|
||||
{selectedFiles}
|
||||
bind:commitMessage
|
||||
{aiEnabled}
|
||||
onDiscard={handleDiscard}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { Trash2, Upload } from 'lucide-svelte';
|
||||
import { Trash2, Upload, Sparkles, Loader2 } from 'lucide-svelte';
|
||||
import ActionsBar from '$ui/actions/ActionsBar.svelte';
|
||||
import ActionButton from '$ui/actions/ActionButton.svelte';
|
||||
import Dropdown from '$ui/dropdown/Dropdown.svelte';
|
||||
import { alertStore } from '$alerts/store';
|
||||
|
||||
export let databaseId: number;
|
||||
export let selectedCount: number;
|
||||
export let selectedFiles: string[] = [];
|
||||
export let commitMessage: string;
|
||||
export let aiEnabled: boolean = false;
|
||||
|
||||
export let onDiscard: () => void;
|
||||
export let onAdd: () => void;
|
||||
|
||||
let generating = false;
|
||||
|
||||
$: canDiscard = selectedCount > 0;
|
||||
$: canAdd = selectedCount > 0 && commitMessage.trim().length > 0;
|
||||
$: canGenerate = aiEnabled && selectedCount > 0 && !generating;
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!canGenerate) return;
|
||||
|
||||
generating = true;
|
||||
try {
|
||||
const response = await fetch(`/api/databases/${databaseId}/generate-commit-message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ files: selectedFiles })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
commitMessage = data.message;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alertStore.add('error', error.message || 'Failed to generate commit message');
|
||||
}
|
||||
} catch (err) {
|
||||
alertStore.add('error', 'Failed to generate commit message');
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ActionsBar className="w-full">
|
||||
@@ -28,6 +60,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if aiEnabled}
|
||||
<ActionButton
|
||||
icon={generating ? Loader2 : Sparkles}
|
||||
iconClass={generating ? 'animate-spin' : ''}
|
||||
hasDropdown={true}
|
||||
dropdownPosition="right"
|
||||
on:click={canGenerate ? handleGenerate : undefined}
|
||||
>
|
||||
<svelte:fragment slot="dropdown" let:dropdownPosition let:open>
|
||||
<Dropdown position={dropdownPosition} {open} minWidth="12rem">
|
||||
<div class="px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{#if generating}
|
||||
Generating...
|
||||
{:else if !selectedCount}
|
||||
Select changes first
|
||||
{:else}
|
||||
Generate commit message
|
||||
{/if}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</svelte:fragment>
|
||||
</ActionButton>
|
||||
{/if}
|
||||
|
||||
<ActionButton
|
||||
icon={Upload}
|
||||
hasDropdown={true}
|
||||
|
||||
@@ -2,12 +2,14 @@ import type { Actions, RequestEvent } from '@sveltejs/kit';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { logSettingsQueries } from '$db/queries/logSettings.ts';
|
||||
import { backupSettingsQueries } from '$db/queries/backupSettings.ts';
|
||||
import { aiSettingsQueries } from '$db/queries/aiSettings.ts';
|
||||
import { logSettings } from '$logger/settings.ts';
|
||||
import { logger } from '$logger/logger.ts';
|
||||
|
||||
export const load = () => {
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import LoggingSettings from './components/LoggingSettings.svelte';
|
||||
import BackupSettings from './components/BackupSettings.svelte';
|
||||
import AISettings from './components/AISettings.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -20,5 +21,8 @@
|
||||
|
||||
<!-- Logging Configuration -->
|
||||
<LoggingSettings settings={data.logSettings} />
|
||||
|
||||
<!-- AI Configuration -->
|
||||
<AISettings settings={data.aiSettings} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
181
src/routes/settings/general/components/AISettings.svelte
Normal file
181
src/routes/settings/general/components/AISettings.svelte
Normal file
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { alertStore } from '$alerts/store';
|
||||
import { Save, RotateCcw, Eye, EyeOff } from 'lucide-svelte';
|
||||
import type { AISettings } from './types';
|
||||
|
||||
export let settings: AISettings;
|
||||
|
||||
let showApiKey = false;
|
||||
|
||||
// Default values
|
||||
const DEFAULTS = {
|
||||
enabled: false,
|
||||
api_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
model: 'gpt-4o-mini'
|
||||
};
|
||||
|
||||
function resetToDefaults() {
|
||||
settings.enabled = DEFAULTS.enabled;
|
||||
settings.api_url = DEFAULTS.api_url;
|
||||
settings.api_key = DEFAULTS.api_key;
|
||||
settings.model = DEFAULTS.model;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
|
||||
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-50">
|
||||
AI Configuration
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Configure AI-powered features like commit message generation. Works with OpenAI, Ollama, LM Studio, or any OpenAI-compatible API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateAI"
|
||||
class="p-6"
|
||||
use:enhance={() => {
|
||||
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();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Enable Toggle -->
|
||||
<div>
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="enabled"
|
||||
bind:checked={settings.enabled}
|
||||
class="h-4 w-4 rounded border-neutral-300 text-accent-600 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-medium text-neutral-900 dark:text-neutral-50">
|
||||
Enable AI Features
|
||||
</span>
|
||||
<p class="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Enable AI-powered commit message generation
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if settings.enabled}
|
||||
<!-- Divider -->
|
||||
<div class="border-t border-neutral-200 dark:border-neutral-800"></div>
|
||||
|
||||
<!-- API URL -->
|
||||
<div>
|
||||
<label
|
||||
for="api_url"
|
||||
class="mb-2 block text-sm font-semibold text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
API URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="api_url"
|
||||
name="api_url"
|
||||
bind:value={settings.api_url}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:ring-1 focus:ring-accent-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
OpenAI-compatible endpoint. Examples: Ollama (http://localhost:11434/v1), LM Studio (http://localhost:1234/v1)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div>
|
||||
<label
|
||||
for="api_key"
|
||||
class="mb-2 block text-sm font-semibold text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
API Key
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
id="api_key"
|
||||
name="api_key"
|
||||
bind:value={settings.api_key}
|
||||
placeholder="sk-..."
|
||||
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 pr-10 font-mono text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:ring-1 focus:ring-accent-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (showApiKey = !showApiKey)}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
>
|
||||
{#if showApiKey}
|
||||
<EyeOff size={16} />
|
||||
{:else}
|
||||
<Eye size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Required for cloud providers. Leave empty for local APIs like Ollama.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Model -->
|
||||
<div>
|
||||
<label
|
||||
for="model"
|
||||
class="mb-2 block text-sm font-semibold text-neutral-900 dark:text-neutral-50"
|
||||
>
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="model"
|
||||
name="model"
|
||||
bind:value={settings.model}
|
||||
placeholder="gpt-4o-mini"
|
||||
class="block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 font-mono text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:ring-1 focus:ring-accent-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:placeholder-neutral-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Model name. Examples: gpt-4o-mini, llama3.2, claude-3-haiku
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div
|
||||
class="mt-6 flex items-center justify-between border-t border-neutral-200 pt-6 dark:border-neutral-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={resetToDefaults}
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 focus:ring-2 focus:ring-accent-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
Reset to Defaults
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white hover:bg-accent-700 focus:ring-2 focus:ring-accent-500 focus:outline-none dark:bg-accent-500 dark:hover:bg-accent-600"
|
||||
>
|
||||
<Save size={16} />
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user