@myrialabs/clopen 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +5 -0
- package/.env.example +2 -5
- package/CONTRIBUTING.md +4 -0
- package/README.md +4 -2
- package/backend/database/queries/message-queries.ts +42 -0
- package/backend/database/utils/connection.ts +5 -5
- package/backend/engine/adapters/claude/environment.ts +3 -4
- package/backend/engine/adapters/claude/stream.ts +107 -0
- package/backend/engine/adapters/opencode/server.ts +7 -1
- package/backend/engine/adapters/opencode/stream.ts +81 -1
- package/backend/engine/types.ts +17 -0
- package/backend/git/git-executor.ts +2 -1
- package/backend/git/git-service.ts +2 -1
- package/backend/index.ts +10 -10
- package/backend/snapshot/blob-store.ts +2 -2
- package/backend/utils/env.ts +13 -15
- package/backend/utils/index.ts +4 -1
- package/backend/utils/paths.ts +11 -0
- package/backend/utils/port-utils.ts +19 -6
- package/backend/ws/git/commit-message.ts +108 -0
- package/backend/ws/git/index.ts +3 -1
- package/backend/ws/messages/crud.ts +52 -0
- package/backend/ws/system/index.ts +7 -1
- package/backend/ws/system/operations.ts +28 -2
- package/bin/clopen.ts +15 -15
- package/docker-compose.yml +31 -0
- package/frontend/App.svelte +3 -0
- package/frontend/components/auth/SetupPage.svelte +45 -13
- package/frontend/components/chat/input/ChatInput.svelte +1 -1
- package/frontend/components/chat/message/ChatMessage.svelte +64 -16
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +124 -10
- package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
- package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
- package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
- package/frontend/components/common/feedback/UpdateBanner.svelte +19 -8
- package/frontend/components/git/BranchManager.svelte +143 -155
- package/frontend/components/git/CommitForm.svelte +61 -11
- package/frontend/components/history/HistoryModal.svelte +30 -78
- package/frontend/components/history/HistoryView.svelte +45 -92
- package/frontend/components/settings/SettingsModal.svelte +1 -1
- package/frontend/components/settings/SettingsView.svelte +1 -1
- package/frontend/components/settings/appearance/AppearanceSettings.svelte +2 -2
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
- package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
- package/frontend/components/settings/git/GitSettings.svelte +392 -0
- package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
- package/frontend/components/settings/model/ModelSettings.svelte +172 -289
- package/frontend/components/workspace/PanelHeader.svelte +1 -3
- package/frontend/components/workspace/WorkspaceLayout.svelte +11 -1
- package/frontend/components/workspace/panels/FilesPanel.svelte +41 -3
- package/frontend/components/workspace/panels/GitPanel.svelte +53 -8
- package/frontend/main.ts +4 -0
- package/frontend/stores/features/auth.svelte.ts +28 -0
- package/frontend/stores/features/settings.svelte.ts +13 -2
- package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
- package/frontend/stores/ui/update.svelte.ts +51 -4
- package/package.json +2 -2
- package/scripts/dev.ts +3 -2
- package/scripts/start.ts +24 -0
- package/shared/types/git.ts +15 -0
- package/shared/types/stores/settings.ts +12 -0
- package/vite.config.ts +2 -2
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import { setCurrentView } from '$frontend/stores/core/app.svelte';
|
|
7
7
|
import ws from '$frontend/utils/ws';
|
|
8
8
|
import type { ChatSession, Project } from '$shared/types/database/schema';
|
|
9
|
-
import type { SDKMessage } from '$shared/types/messaging';
|
|
10
9
|
import Input from '../common/form/Input.svelte';
|
|
11
10
|
import Select from '../common/form/Select.svelte';
|
|
12
11
|
import Icon from '$frontend/components/common/display/Icon.svelte';
|
|
@@ -62,7 +61,6 @@
|
|
|
62
61
|
|
|
63
62
|
// Cache for session data to avoid multiple API calls
|
|
64
63
|
const sessionDataCache = $state<Record<string, {
|
|
65
|
-
messages: SDKMessage[];
|
|
66
64
|
title: string;
|
|
67
65
|
summary: string;
|
|
68
66
|
count: number;
|
|
@@ -71,95 +69,30 @@
|
|
|
71
69
|
}>>({});
|
|
72
70
|
let loadingSessionData = $state(true);
|
|
73
71
|
|
|
74
|
-
// Helper to get session data from cache or API
|
|
72
|
+
// Helper to get session data from cache or API (single session fallback)
|
|
75
73
|
async function getSessionData(sessionId: string) {
|
|
76
74
|
if (sessionDataCache[sessionId]) {
|
|
77
75
|
return sessionDataCache[sessionId];
|
|
78
76
|
}
|
|
79
77
|
|
|
80
78
|
try {
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
} else if (Array.isArray(firstUserMessage.message.content)) {
|
|
93
|
-
// Extract text from content blocks
|
|
94
|
-
const textBlocks = firstUserMessage.message.content.filter((c: any) => c.type === 'text');
|
|
95
|
-
textContent = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (textContent) {
|
|
99
|
-
title = textContent.slice(0, 60) + (textContent.length > 60 ? '...' : '');
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Get summary from last assistant message in current HEAD checkpoint
|
|
104
|
-
const assistantMessages = messages.filter((m: SDKMessage) => m.type === 'assistant');
|
|
105
|
-
let summary = 'No messages yet';
|
|
106
|
-
if (assistantMessages.length > 0) {
|
|
107
|
-
const lastMessage = assistantMessages[assistantMessages.length - 1];
|
|
108
|
-
const textBlocks = lastMessage.message.content.filter((c: any) => c.type === 'text');
|
|
109
|
-
if (textBlocks.length > 0) {
|
|
110
|
-
const fullText = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
|
|
111
|
-
const cleanText = fullText.replace(/```[\s\S]*?```/g, '').trim();
|
|
112
|
-
summary = cleanText.slice(0, 150) + (cleanText.length > 150 ? '...' : '');
|
|
113
|
-
}
|
|
79
|
+
const previews = await ws.http('sessions:preview', { session_ids: [sessionId] });
|
|
80
|
+
const preview = previews[0];
|
|
81
|
+
if (preview) {
|
|
82
|
+
sessionDataCache[sessionId] = {
|
|
83
|
+
title: preview.title,
|
|
84
|
+
summary: preview.summary,
|
|
85
|
+
count: preview.count,
|
|
86
|
+
userCount: preview.userCount,
|
|
87
|
+
assistantCount: preview.assistantCount
|
|
88
|
+
};
|
|
89
|
+
return sessionDataCache[sessionId];
|
|
114
90
|
}
|
|
115
|
-
|
|
116
|
-
// Count user and assistant messages in current HEAD checkpoint
|
|
117
|
-
// Filter out empty user messages (same as ChatInterface.svelte timeline logic)
|
|
118
|
-
const userMessages = messages.filter((m: SDKMessage) => {
|
|
119
|
-
if (m.type !== 'user') return false;
|
|
120
|
-
// Extract text content
|
|
121
|
-
let textContent = '';
|
|
122
|
-
if (typeof m.message.content === 'string') {
|
|
123
|
-
textContent = m.message.content;
|
|
124
|
-
} else if (Array.isArray(m.message.content)) {
|
|
125
|
-
const textBlocks = m.message.content.filter(c => c.type === 'text');
|
|
126
|
-
textContent = textBlocks.map(b => 'text' in b ? b.text : '').join(' ');
|
|
127
|
-
}
|
|
128
|
-
return textContent.trim().length > 0;
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
const totalBubbles = userMessages.length + assistantMessages.length; // Total message bubbles in chat
|
|
132
|
-
|
|
133
|
-
const data = {
|
|
134
|
-
messages,
|
|
135
|
-
title,
|
|
136
|
-
summary,
|
|
137
|
-
count: totalBubbles, // Total bubbles (user + assistant with non-empty content)
|
|
138
|
-
userCount: userMessages.length, // Number of chat sessions/exchanges (non-empty user messages)
|
|
139
|
-
assistantCount: assistantMessages.length
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
sessionDataCache[sessionId] = data;
|
|
143
|
-
debug.log('session', `Loaded session ${sessionId}:`, {
|
|
144
|
-
title,
|
|
145
|
-
totalMessages: messages.length,
|
|
146
|
-
userCount: userMessages.length,
|
|
147
|
-
assistantCount: assistantMessages.length,
|
|
148
|
-
totalBubbles: totalBubbles,
|
|
149
|
-
summary: summary.substring(0, 50)
|
|
150
|
-
});
|
|
151
|
-
return data;
|
|
152
91
|
} catch (error) {
|
|
153
92
|
debug.error('session', 'Error fetching session data:', error);
|
|
154
|
-
return {
|
|
155
|
-
messages: [],
|
|
156
|
-
title: 'New Conversation',
|
|
157
|
-
summary: 'No messages yet',
|
|
158
|
-
count: 0,
|
|
159
|
-
userCount: 0,
|
|
160
|
-
assistantCount: 0
|
|
161
|
-
};
|
|
162
93
|
}
|
|
94
|
+
|
|
95
|
+
return { title: 'New Conversation', summary: 'No messages yet', count: 0, userCount: 0, assistantCount: 0 };
|
|
163
96
|
}
|
|
164
97
|
|
|
165
98
|
// Helper functions that use cached data
|
|
@@ -179,14 +112,23 @@
|
|
|
179
112
|
return sessionDataCache[sessionId]?.summary || 'No messages yet';
|
|
180
113
|
}
|
|
181
114
|
|
|
182
|
-
// Preload session data for visible sessions
|
|
115
|
+
// Preload session data for visible sessions using a single bulk request
|
|
183
116
|
async function preloadSessionData() {
|
|
184
117
|
loadingSessionData = true;
|
|
185
118
|
try {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
);
|
|
119
|
+
const sessionIds = sessions.slice(0, 20).map(s => s.id);
|
|
120
|
+
if (sessionIds.length === 0) return;
|
|
121
|
+
|
|
122
|
+
const previews = await ws.http('sessions:preview', { session_ids: sessionIds });
|
|
123
|
+
for (const preview of previews) {
|
|
124
|
+
sessionDataCache[preview.session_id] = {
|
|
125
|
+
title: preview.title,
|
|
126
|
+
summary: preview.summary,
|
|
127
|
+
count: preview.count,
|
|
128
|
+
userCount: preview.userCount,
|
|
129
|
+
assistantCount: preview.assistantCount
|
|
130
|
+
};
|
|
131
|
+
}
|
|
190
132
|
} catch (error) {
|
|
191
133
|
debug.error('session', 'Error preloading session data:', error);
|
|
192
134
|
} finally {
|
|
@@ -199,14 +141,25 @@
|
|
|
199
141
|
preloadSessionData();
|
|
200
142
|
});
|
|
201
143
|
|
|
202
|
-
// Reload session data when sessions
|
|
144
|
+
// Reload session data when new sessions arrive
|
|
203
145
|
$effect(() => {
|
|
204
146
|
if (sessions.length > 0 && !loadingSessionData) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
147
|
+
const uncachedIds = sessions.filter(s => !sessionDataCache[s.id]).map(s => s.id);
|
|
148
|
+
if (uncachedIds.length > 0) {
|
|
149
|
+
debug.log('session', `Found ${uncachedIds.length} uncached sessions, loading...`);
|
|
150
|
+
ws.http('sessions:preview', { session_ids: uncachedIds }).then((previews: any[]) => {
|
|
151
|
+
for (const preview of previews) {
|
|
152
|
+
sessionDataCache[preview.session_id] = {
|
|
153
|
+
title: preview.title,
|
|
154
|
+
summary: preview.summary,
|
|
155
|
+
count: preview.count,
|
|
156
|
+
userCount: preview.userCount,
|
|
157
|
+
assistantCount: preview.assistantCount
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}).catch((err: unknown) => {
|
|
161
|
+
debug.error('session', 'Error loading uncached sessions:', err);
|
|
162
|
+
});
|
|
210
163
|
}
|
|
211
164
|
}
|
|
212
165
|
});
|
|
@@ -219,7 +219,7 @@
|
|
|
219
219
|
<!-- Content Area -->
|
|
220
220
|
<main class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
221
221
|
<div class="flex-1 overflow-y-auto p-4 md:p-5">
|
|
222
|
-
{#if activeSection === '
|
|
222
|
+
{#if activeSection === 'models'}
|
|
223
223
|
<div in:fly={{ x: 20, duration: 200 }}>
|
|
224
224
|
<ModelSettings />
|
|
225
225
|
</div>
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import { settings, updateSettings, applyFontSize } from '$frontend/stores/features/settings.svelte';
|
|
4
4
|
import Icon from '../../common/display/Icon.svelte';
|
|
5
5
|
|
|
6
|
-
const FONT_SIZE_MIN =
|
|
7
|
-
const FONT_SIZE_MAX =
|
|
6
|
+
const FONT_SIZE_MIN = 8;
|
|
7
|
+
const FONT_SIZE_MAX = 24;
|
|
8
8
|
|
|
9
9
|
function handleFontSizeChange(e: Event) {
|
|
10
10
|
const value = Number((e.target as HTMLInputElement).value);
|
|
@@ -383,9 +383,9 @@
|
|
|
383
383
|
|
|
384
384
|
<div class="space-y-6">
|
|
385
385
|
<!-- Header -->
|
|
386
|
-
<h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">
|
|
386
|
+
<h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Engines</h3>
|
|
387
387
|
<p class="text-sm text-slate-600 dark:text-slate-500 mb-4">
|
|
388
|
-
Manage
|
|
388
|
+
Manage engine installations and accounts
|
|
389
389
|
</p>
|
|
390
390
|
|
|
391
391
|
<!-- Claude Code Card -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { systemSettings, updateSystemSettings } from '$frontend/stores/features/settings.svelte';
|
|
3
|
-
import { updateState, checkForUpdate, runUpdate } from '$frontend/stores/ui/update.svelte';
|
|
3
|
+
import { updateState, checkForUpdate, runUpdate, showRestartModal } from '$frontend/stores/ui/update.svelte';
|
|
4
4
|
import Icon from '../../common/display/Icon.svelte';
|
|
5
5
|
|
|
6
6
|
function toggleAutoUpdate() {
|
|
@@ -88,10 +88,17 @@
|
|
|
88
88
|
</div>
|
|
89
89
|
{/if}
|
|
90
90
|
|
|
91
|
-
{#if updateState.updateSuccess}
|
|
91
|
+
{#if updateState.updateSuccess || updateState.pendingRestart}
|
|
92
92
|
<div class="mt-3 flex items-center gap-2 px-3 py-2 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
|
|
93
93
|
<Icon name="lucide:circle-check" class="w-4 h-4 text-emerald-500 shrink-0" />
|
|
94
|
-
<span class="text-xs text-emerald-600 dark:text-emerald-400">Updated
|
|
94
|
+
<span class="text-xs text-emerald-600 dark:text-emerald-400">Updated to v{updateState.latestVersion} — restart required</span>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onclick={() => showRestartModal()}
|
|
98
|
+
class="text-xs font-semibold text-emerald-600 dark:text-emerald-400 underline underline-offset-2 hover:text-emerald-700 dark:hover:text-emerald-300 transition-colors"
|
|
99
|
+
>
|
|
100
|
+
How to restart
|
|
101
|
+
</button>
|
|
95
102
|
</div>
|
|
96
103
|
{/if}
|
|
97
104
|
</div>
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Icon from '$frontend/components/common/display/Icon.svelte';
|
|
3
|
+
import { settings, updateSettings } from '$frontend/stores/features/settings.svelte';
|
|
4
|
+
import { modelStore } from '$frontend/stores/features/models.svelte';
|
|
5
|
+
import { ENGINES } from '$shared/constants/engines';
|
|
6
|
+
import type { EngineType } from '$shared/types/engine';
|
|
7
|
+
import type { EngineModel } from '$shared/types/engine';
|
|
8
|
+
import type { CommitMessageFormat } from '$shared/types/git';
|
|
9
|
+
import type { IconName } from '$shared/types/ui/icons';
|
|
10
|
+
|
|
11
|
+
const formatOptions: { id: CommitMessageFormat; label: string; desc: string; icon: IconName }[] = [
|
|
12
|
+
{ id: 'single-line', label: 'Single Line', desc: 'type(scope): subject', icon: 'lucide:minus' },
|
|
13
|
+
{ id: 'multi-line', label: 'Multi Line', desc: 'Subject + body', icon: 'lucide:align-left' }
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
let searchQuery = $state('');
|
|
17
|
+
let refreshing = $state(false);
|
|
18
|
+
let collapsedProviders = $state<Set<string>>(new Set());
|
|
19
|
+
|
|
20
|
+
const commitGen = $derived(settings.commitGenerator);
|
|
21
|
+
const useCustomModel = $derived(commitGen.useCustomModel);
|
|
22
|
+
|
|
23
|
+
// Resolve which model is being used for display
|
|
24
|
+
const activeEngine = $derived(useCustomModel ? commitGen.engine : settings.selectedEngine);
|
|
25
|
+
const activeModel = $derived(useCustomModel ? commitGen.model : settings.selectedModel);
|
|
26
|
+
const activeEngineMeta = $derived(ENGINES.find(e => e.type === activeEngine));
|
|
27
|
+
const activeModelMeta = $derived(modelStore.getById(activeModel));
|
|
28
|
+
|
|
29
|
+
// Models for the custom engine, filtered by search
|
|
30
|
+
const filteredModels = $derived.by(() => {
|
|
31
|
+
const models = modelStore.getByEngine(commitGen.engine);
|
|
32
|
+
if (!searchQuery.trim()) return models;
|
|
33
|
+
const q = searchQuery.toLowerCase();
|
|
34
|
+
return models.filter(m =>
|
|
35
|
+
m.name.toLowerCase().includes(q) ||
|
|
36
|
+
m.modelId.toLowerCase().includes(q) ||
|
|
37
|
+
m.provider.toLowerCase().includes(q) ||
|
|
38
|
+
m.capabilities.some(c => c.toLowerCase().includes(q))
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Group models by provider
|
|
43
|
+
const groupedModels = $derived.by(() => {
|
|
44
|
+
const groups = new Map<string, EngineModel[]>();
|
|
45
|
+
for (const model of filteredModels) {
|
|
46
|
+
const key = model.provider;
|
|
47
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
48
|
+
groups.get(key)!.push(model);
|
|
49
|
+
}
|
|
50
|
+
return groups;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Fetch models when custom engine changes (for opencode)
|
|
54
|
+
$effect(() => {
|
|
55
|
+
if (useCustomModel && commitGen.engine !== 'claude-code') {
|
|
56
|
+
modelStore.fetchModels(commitGen.engine);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Sync accordion state when search or models change
|
|
61
|
+
$effect(() => {
|
|
62
|
+
if (!useCustomModel) return;
|
|
63
|
+
if (searchQuery.trim()) {
|
|
64
|
+
collapsedProviders = new Set();
|
|
65
|
+
} else if (groupedModels.size > 0) {
|
|
66
|
+
syncAccordionState();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function syncAccordionState() {
|
|
71
|
+
const allProviders = [...groupedModels.keys()];
|
|
72
|
+
let selectedProvider: string | null = null;
|
|
73
|
+
for (const [provider, models] of groupedModels) {
|
|
74
|
+
if (models.some(m => m.id === commitGen.model)) {
|
|
75
|
+
selectedProvider = provider;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const collapsed = new Set(allProviders);
|
|
80
|
+
if (selectedProvider) collapsed.delete(selectedProvider);
|
|
81
|
+
collapsedProviders = collapsed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toggleProvider(provider: string) {
|
|
85
|
+
const next = new Set(collapsedProviders);
|
|
86
|
+
if (next.has(provider)) next.delete(provider);
|
|
87
|
+
else next.add(provider);
|
|
88
|
+
collapsedProviders = next;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatProvider(provider: string): string {
|
|
92
|
+
return provider.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toggleCustomModel() {
|
|
96
|
+
updateSettings({
|
|
97
|
+
commitGenerator: { ...commitGen, useCustomModel: !useCustomModel }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function selectEngine(engineType: EngineType) {
|
|
102
|
+
const models = modelStore.getByEngine(engineType);
|
|
103
|
+
const defaultModel = engineType === 'claude-code'
|
|
104
|
+
? 'claude-code:haiku'
|
|
105
|
+
: (models[0]?.id || '');
|
|
106
|
+
updateSettings({
|
|
107
|
+
commitGenerator: {
|
|
108
|
+
...commitGen,
|
|
109
|
+
engine: engineType,
|
|
110
|
+
model: defaultModel
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
searchQuery = '';
|
|
114
|
+
|
|
115
|
+
if (engineType !== 'claude-code') {
|
|
116
|
+
const fetched = await modelStore.fetchModels(engineType);
|
|
117
|
+
if (fetched.length > 0) {
|
|
118
|
+
updateSettings({
|
|
119
|
+
commitGenerator: { ...settings.commitGenerator, model: fetched[0].id }
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
syncAccordionState();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function selectModel(modelId: string) {
|
|
128
|
+
updateSettings({
|
|
129
|
+
commitGenerator: { ...commitGen, model: modelId }
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function selectFormat(format: CommitMessageFormat) {
|
|
134
|
+
updateSettings({
|
|
135
|
+
commitGenerator: { ...commitGen, format }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function handleRefresh() {
|
|
140
|
+
refreshing = true;
|
|
141
|
+
try {
|
|
142
|
+
await modelStore.refreshModels(commitGen.engine);
|
|
143
|
+
} finally {
|
|
144
|
+
refreshing = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
</script>
|
|
148
|
+
|
|
149
|
+
<div class="py-1">
|
|
150
|
+
<h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Commit Message</h3>
|
|
151
|
+
<p class="text-sm text-slate-600 dark:text-slate-500 mb-4">
|
|
152
|
+
Generate conventional commit messages from staged changes using AI
|
|
153
|
+
</p>
|
|
154
|
+
|
|
155
|
+
<!-- Current Model Info -->
|
|
156
|
+
<div class="mb-5">
|
|
157
|
+
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Model</label>
|
|
158
|
+
<div class="flex items-center gap-3 px-3.5 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
|
159
|
+
{#if activeEngineMeta}
|
|
160
|
+
<div class="flex-shrink-0">
|
|
161
|
+
<div class="flex dark:hidden items-center justify-center w-4 h-4">{@html activeEngineMeta.icon.light}</div>
|
|
162
|
+
<div class="hidden dark:flex items-center justify-center w-4 h-4">{@html activeEngineMeta.icon.dark}</div>
|
|
163
|
+
</div>
|
|
164
|
+
{/if}
|
|
165
|
+
<div class="flex-1 min-w-0">
|
|
166
|
+
<span class="text-sm font-medium text-slate-900 dark:text-slate-100">
|
|
167
|
+
{activeModelMeta?.name || activeModel}
|
|
168
|
+
</span>
|
|
169
|
+
{#if !useCustomModel}
|
|
170
|
+
<span class="text-xs text-slate-500 dark:text-slate-400 ml-1.5">(same as assistant)</span>
|
|
171
|
+
{/if}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<!-- Custom Model Toggle -->
|
|
177
|
+
<div class="mb-5">
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
class="flex items-center gap-3 w-full text-left"
|
|
181
|
+
onclick={toggleCustomModel}
|
|
182
|
+
>
|
|
183
|
+
<div class="relative w-9 h-5 rounded-full transition-colors duration-200 flex-shrink-0
|
|
184
|
+
{useCustomModel ? 'bg-violet-600' : 'bg-slate-300 dark:bg-slate-600'}">
|
|
185
|
+
<div class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-200
|
|
186
|
+
{useCustomModel ? 'translate-x-4.5' : 'translate-x-0.5'}"></div>
|
|
187
|
+
</div>
|
|
188
|
+
<div>
|
|
189
|
+
<span class="text-sm font-medium text-slate-900 dark:text-slate-100">Use custom model</span>
|
|
190
|
+
<p class="text-xs text-slate-500 dark:text-slate-400">Use a different engine and model instead of the assistant model</p>
|
|
191
|
+
</div>
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<!-- Custom Engine & Model Selection (only when toggled on) -->
|
|
196
|
+
{#if useCustomModel}
|
|
197
|
+
<!-- Engine Selection -->
|
|
198
|
+
<div class="mb-6">
|
|
199
|
+
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Engine</label>
|
|
200
|
+
<div class="flex gap-3">
|
|
201
|
+
{#each ENGINES as engine (engine.type)}
|
|
202
|
+
{@const isActive = commitGen.engine === engine.type}
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
class="flex-1 flex items-center gap-3 p-3.5 overflow-hidden border-2 rounded-xl text-left cursor-pointer transition-all duration-200
|
|
206
|
+
{isActive
|
|
207
|
+
? 'border-violet-600 bg-gradient-to-br from-violet-500/10 to-purple-500/5 dark:from-violet-500/12 dark:to-purple-500/8'
|
|
208
|
+
: 'border-slate-200 dark:border-slate-800 bg-slate-100/80 dark:bg-slate-800/80 hover:border-violet-500/20 dark:hover:border-violet-500/35'}"
|
|
209
|
+
onclick={() => selectEngine(engine.type)}
|
|
210
|
+
>
|
|
211
|
+
<div>
|
|
212
|
+
<div class="flex dark:hidden items-center justify-center w-5 h-5">{@html engine.icon.light}</div>
|
|
213
|
+
<div class="hidden dark:flex items-center justify-center w-5 h-5">{@html engine.icon.dark}</div>
|
|
214
|
+
</div>
|
|
215
|
+
<div>
|
|
216
|
+
<div class="font-bold text-sm text-slate-900 dark:text-slate-100">{engine.name}</div>
|
|
217
|
+
<div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{engine.description}</div>
|
|
218
|
+
</div>
|
|
219
|
+
{#if isActive}
|
|
220
|
+
<div class="flex items-center justify-center w-5 h-5 bg-gradient-to-br from-violet-600 to-purple-600 rounded-full text-white ml-auto flex-shrink-0">
|
|
221
|
+
<svg viewBox="0 0 24 24" fill="none" class="w-3 h-3" aria-hidden="true">
|
|
222
|
+
<path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
|
223
|
+
</svg>
|
|
224
|
+
</div>
|
|
225
|
+
{/if}
|
|
226
|
+
</button>
|
|
227
|
+
{/each}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<!-- Model Selection -->
|
|
232
|
+
<div class="mb-6">
|
|
233
|
+
<div class="flex items-center justify-between mb-1.5">
|
|
234
|
+
<label class="text-sm font-semibold text-slate-700 dark:text-slate-300">Model</label>
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors cursor-pointer
|
|
238
|
+
text-slate-500 hover:text-violet-600 hover:bg-violet-500/10 dark:hover:text-violet-400 dark:hover:bg-violet-500/15
|
|
239
|
+
disabled:opacity-50 disabled:cursor-not-allowed"
|
|
240
|
+
onclick={handleRefresh}
|
|
241
|
+
disabled={refreshing || modelStore.loading}
|
|
242
|
+
>
|
|
243
|
+
<svg viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5 {refreshing ? 'animate-spin' : ''}" aria-hidden="true">
|
|
244
|
+
<path d="M21 12a9 9 0 11-2.636-6.364M21 3v5h-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
245
|
+
</svg>
|
|
246
|
+
{refreshing ? 'Refreshing...' : 'Refresh'}
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
<p class="text-sm text-slate-600 dark:text-slate-500 mb-3">
|
|
250
|
+
Select the model for the {ENGINES.find(e => e.type === commitGen.engine)?.name || 'selected'} engine
|
|
251
|
+
</p>
|
|
252
|
+
|
|
253
|
+
<!-- Search -->
|
|
254
|
+
<div class="relative mb-3">
|
|
255
|
+
<svg viewBox="0 0 24 24" fill="none" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" aria-hidden="true">
|
|
256
|
+
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
|
|
257
|
+
<path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
258
|
+
</svg>
|
|
259
|
+
<input
|
|
260
|
+
type="text"
|
|
261
|
+
bind:value={searchQuery}
|
|
262
|
+
placeholder="Search models..."
|
|
263
|
+
class="w-full pl-9 pr-3 py-2 text-sm bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-600 transition-colors text-slate-900 dark:text-slate-100 placeholder-slate-400"
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<!-- Model List -->
|
|
268
|
+
<div class="flex flex-col gap-1.5">
|
|
269
|
+
{#if modelStore.loading && commitGen.engine !== 'claude-code' && !refreshing}
|
|
270
|
+
<!-- Loading skeleton -->
|
|
271
|
+
<div class="border border-slate-200/80 dark:border-slate-700/50 rounded-lg overflow-hidden">
|
|
272
|
+
<div class="bg-white/80 dark:bg-slate-800/40 px-3 py-3 flex items-center gap-3">
|
|
273
|
+
<div class="w-4 h-4 rounded-full bg-slate-200 dark:bg-slate-700 animate-pulse"></div>
|
|
274
|
+
<div class="h-3.5 w-32 rounded bg-slate-200 dark:bg-slate-700 animate-pulse"></div>
|
|
275
|
+
</div>
|
|
276
|
+
<div class="px-4 py-2.5 space-y-2.5">
|
|
277
|
+
{#each Array(3) as _}
|
|
278
|
+
<div class="flex items-center gap-3 py-2">
|
|
279
|
+
<div class="w-4 h-4 rounded-full bg-slate-200/80 dark:bg-slate-700/60 animate-pulse"></div>
|
|
280
|
+
<div class="flex-1 space-y-1.5">
|
|
281
|
+
<div class="h-3.5 w-40 rounded bg-slate-200/80 dark:bg-slate-700/60 animate-pulse"></div>
|
|
282
|
+
<div class="flex gap-1.5">
|
|
283
|
+
<div class="h-3 w-14 rounded bg-slate-200/60 dark:bg-slate-700/40 animate-pulse"></div>
|
|
284
|
+
<div class="h-3 w-12 rounded bg-slate-200/60 dark:bg-slate-700/40 animate-pulse"></div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
{/each}
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
{:else if filteredModels.length === 0}
|
|
292
|
+
<div class="py-4 text-sm text-slate-500 text-center">
|
|
293
|
+
{searchQuery ? 'No models matching your search.' : 'No models available for this engine.'}
|
|
294
|
+
</div>
|
|
295
|
+
{:else}
|
|
296
|
+
{#each [...groupedModels.entries()] as [provider, providerModels] (provider)}
|
|
297
|
+
{@const isCollapsed = collapsedProviders.has(provider)}
|
|
298
|
+
{@const hasSelectedModel = providerModels.some(m => m.id === commitGen.model)}
|
|
299
|
+
<div class="border border-slate-200/80 dark:border-slate-700/50 rounded-lg overflow-hidden">
|
|
300
|
+
<!-- Accordion header -->
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
class="flex items-center gap-2.5 w-full px-3 py-2.5 text-left cursor-pointer transition-colors
|
|
304
|
+
bg-white/80 dark:bg-slate-800/40 hover:bg-white dark:hover:bg-slate-800/60"
|
|
305
|
+
onclick={() => toggleProvider(provider)}
|
|
306
|
+
>
|
|
307
|
+
<svg viewBox="0 0 24 24" fill="none"
|
|
308
|
+
class="w-4 h-4 text-slate-400 transition-transform duration-200 flex-shrink-0
|
|
309
|
+
{isCollapsed ? '' : 'rotate-90'}"
|
|
310
|
+
aria-hidden="true">
|
|
311
|
+
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
312
|
+
</svg>
|
|
313
|
+
<span class="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
|
314
|
+
{formatProvider(provider)}
|
|
315
|
+
</span>
|
|
316
|
+
<span class="text-xs text-slate-400 dark:text-slate-500">
|
|
317
|
+
{providerModels.length} {providerModels.length === 1 ? 'model' : 'models'}
|
|
318
|
+
</span>
|
|
319
|
+
{#if hasSelectedModel}
|
|
320
|
+
<div class="w-1.5 h-1.5 rounded-full bg-violet-500 ml-auto flex-shrink-0"></div>
|
|
321
|
+
{/if}
|
|
322
|
+
</button>
|
|
323
|
+
|
|
324
|
+
<!-- Accordion body -->
|
|
325
|
+
{#if !isCollapsed}
|
|
326
|
+
<div class="flex flex-col bg-white/40 dark:bg-slate-800/20">
|
|
327
|
+
{#each providerModels as model (model.id)}
|
|
328
|
+
{@const isSelected = commitGen.model === model.id}
|
|
329
|
+
{@const caps = model.capabilities}
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
class="flex items-start gap-3 px-3 py-2.5 text-left cursor-pointer transition-all duration-150
|
|
333
|
+
{isSelected
|
|
334
|
+
? 'bg-violet-500/10 dark:bg-violet-500/12'
|
|
335
|
+
: 'hover:bg-slate-100/80 dark:hover:bg-slate-700/30'}"
|
|
336
|
+
onclick={() => selectModel(model.id)}
|
|
337
|
+
>
|
|
338
|
+
<div class="flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center mt-0.5
|
|
339
|
+
{isSelected ? 'border-violet-600' : 'border-slate-300 dark:border-slate-600'}">
|
|
340
|
+
{#if isSelected}
|
|
341
|
+
<div class="w-2 h-2 rounded-full bg-violet-600"></div>
|
|
342
|
+
{/if}
|
|
343
|
+
</div>
|
|
344
|
+
<div class="flex-1 min-w-0">
|
|
345
|
+
<div class="flex items-center gap-2">
|
|
346
|
+
<span class="text-sm font-medium text-slate-900 dark:text-slate-100">{model.name}</span>
|
|
347
|
+
</div>
|
|
348
|
+
{#if caps.length > 0}
|
|
349
|
+
<div class="flex flex-wrap gap-1 mt-1.5">
|
|
350
|
+
{#each caps as cap}
|
|
351
|
+
<span class="px-1.5 py-0.5 text-2xs rounded bg-slate-100 dark:bg-slate-700/50 text-slate-500 dark:text-slate-400 leading-none">
|
|
352
|
+
{cap}
|
|
353
|
+
</span>
|
|
354
|
+
{/each}
|
|
355
|
+
</div>
|
|
356
|
+
{/if}
|
|
357
|
+
</div>
|
|
358
|
+
</button>
|
|
359
|
+
{/each}
|
|
360
|
+
</div>
|
|
361
|
+
{/if}
|
|
362
|
+
</div>
|
|
363
|
+
{/each}
|
|
364
|
+
{/if}
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
{/if}
|
|
368
|
+
|
|
369
|
+
<!-- Format Selection -->
|
|
370
|
+
<div class="mb-2">
|
|
371
|
+
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Message Format</label>
|
|
372
|
+
<div class="flex gap-2">
|
|
373
|
+
{#each formatOptions as fmt (fmt.id)}
|
|
374
|
+
{@const isActive = commitGen.format === fmt.id}
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
class="flex-1 flex items-center gap-2.5 p-3 border-2 rounded-xl text-left cursor-pointer transition-all duration-200
|
|
378
|
+
{isActive
|
|
379
|
+
? 'border-violet-600 bg-gradient-to-br from-violet-500/10 to-purple-500/5 dark:from-violet-500/12 dark:to-purple-500/8'
|
|
380
|
+
: 'border-slate-200 dark:border-slate-800 bg-slate-100/80 dark:bg-slate-800/80 hover:border-violet-500/20 dark:hover:border-violet-500/35'}"
|
|
381
|
+
onclick={() => selectFormat(fmt.id)}
|
|
382
|
+
>
|
|
383
|
+
<Icon name={fmt.icon} class="w-4 h-4 {isActive ? 'text-violet-600' : 'text-slate-400'}" />
|
|
384
|
+
<div>
|
|
385
|
+
<div class="text-sm font-medium text-slate-900 dark:text-slate-100">{fmt.label}</div>
|
|
386
|
+
<div class="text-xs text-slate-500 dark:text-slate-400 font-mono">{fmt.desc}</div>
|
|
387
|
+
</div>
|
|
388
|
+
</button>
|
|
389
|
+
{/each}
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|