@myrialabs/clopen 0.2.3 → 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/backend/engine/adapters/claude/stream.ts +107 -0
- package/backend/engine/adapters/opencode/stream.ts +81 -1
- package/backend/engine/types.ts +17 -0
- package/backend/git/git-service.ts +2 -1
- package/backend/ws/git/commit-message.ts +108 -0
- package/backend/ws/git/index.ts +3 -1
- package/backend/ws/system/index.ts +7 -1
- package/backend/ws/system/operations.ts +28 -2
- package/frontend/App.svelte +3 -0
- package/frontend/components/auth/SetupPage.svelte +2 -2
- package/frontend/components/chat/input/ChatInput.svelte +1 -1
- package/frontend/components/chat/message/ChatMessage.svelte +64 -16
- 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 +17 -6
- package/frontend/components/git/BranchManager.svelte +143 -155
- package/frontend/components/git/CommitForm.svelte +61 -11
- package/frontend/components/settings/SettingsModal.svelte +1 -1
- package/frontend/components/settings/SettingsView.svelte +1 -1
- 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/GitPanel.svelte +12 -5
- package/frontend/main.ts +4 -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 +45 -4
- package/package.json +1 -1
- package/shared/types/git.ts +15 -0
- package/shared/types/stores/settings.ts +12 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { query, type SDKMessage, type EngineSDKMessage, type Options, type Query, type SDKUserMessage } from '$shared/types/messaging';
|
|
10
10
|
import type { PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
|
11
|
+
import type { StructuredGenerationOptions } from '../../types';
|
|
11
12
|
import { normalizePath } from './path-utils';
|
|
12
13
|
import { setupEnvironmentOnce, getEngineEnv } from './environment';
|
|
13
14
|
import { handleStreamError } from './error-handler';
|
|
@@ -238,4 +239,110 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
238
239
|
this.pendingUserAnswers.delete(toolUseId);
|
|
239
240
|
return true;
|
|
240
241
|
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One-shot structured JSON generation.
|
|
245
|
+
* Uses query() with no tools, outputFormat, and maxTurns: 1.
|
|
246
|
+
*/
|
|
247
|
+
async generateStructured<T = unknown>(options: StructuredGenerationOptions): Promise<T> {
|
|
248
|
+
const {
|
|
249
|
+
prompt,
|
|
250
|
+
model = 'haiku',
|
|
251
|
+
schema,
|
|
252
|
+
projectPath,
|
|
253
|
+
abortController,
|
|
254
|
+
claudeAccountId
|
|
255
|
+
} = options;
|
|
256
|
+
|
|
257
|
+
if (!this._isInitialized) {
|
|
258
|
+
await this.initialize();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const controller = abortController || new AbortController();
|
|
262
|
+
const normalizedPath = normalizePath(projectPath);
|
|
263
|
+
|
|
264
|
+
// Optimized for one-shot structured generation:
|
|
265
|
+
// - tools: [] prevents tool use (no agentic loops)
|
|
266
|
+
// - persistSession: false skips writing session to disk
|
|
267
|
+
// - effort: 'low' reduces processing overhead for simple tasks
|
|
268
|
+
// - thinking disabled removes reasoning overhead
|
|
269
|
+
// - minimal systemPrompt avoids loading heavy defaults
|
|
270
|
+
// - no maxTurns: structured output has its own retry limit
|
|
271
|
+
const sdkOptions: Options = {
|
|
272
|
+
permissionMode: 'bypassPermissions' as PermissionMode,
|
|
273
|
+
allowDangerouslySkipPermissions: true,
|
|
274
|
+
cwd: normalizedPath,
|
|
275
|
+
env: getEngineEnv(claudeAccountId),
|
|
276
|
+
systemPrompt: 'You are a structured data generator. Return JSON matching the provided schema.',
|
|
277
|
+
tools: [],
|
|
278
|
+
outputFormat: {
|
|
279
|
+
type: 'json_schema',
|
|
280
|
+
schema
|
|
281
|
+
},
|
|
282
|
+
persistSession: false,
|
|
283
|
+
effort: 'low',
|
|
284
|
+
thinking: { type: 'disabled' },
|
|
285
|
+
...(model && { model }),
|
|
286
|
+
abortController: controller
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Use plain string prompt — simpler and faster than AsyncIterable
|
|
290
|
+
const queryInstance = query({
|
|
291
|
+
prompt,
|
|
292
|
+
options: sdkOptions
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
let structuredOutput: unknown = null;
|
|
296
|
+
let resultText = '';
|
|
297
|
+
let lastError = '';
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
for await (const message of queryInstance) {
|
|
301
|
+
debug.log('engine', `[structured] message type=${message.type}, subtype=${'subtype' in message ? message.subtype : 'n/a'}`);
|
|
302
|
+
|
|
303
|
+
if (message.type === 'result') {
|
|
304
|
+
if (message.subtype === 'success') {
|
|
305
|
+
const result = message as any;
|
|
306
|
+
structuredOutput = result.structured_output;
|
|
307
|
+
resultText = result.result || '';
|
|
308
|
+
debug.log('engine', `[structured] success: structured_output=${!!structuredOutput}, resultLen=${resultText.length}`);
|
|
309
|
+
} else {
|
|
310
|
+
const errResult = message as any;
|
|
311
|
+
lastError = errResult.errors?.join('; ') || '';
|
|
312
|
+
const subtype = errResult.subtype || '';
|
|
313
|
+
|
|
314
|
+
// Map SDK error subtypes to user-friendly messages
|
|
315
|
+
if (subtype === 'error_max_structured_output_retries') {
|
|
316
|
+
lastError = 'Failed to generate valid structured output after multiple attempts';
|
|
317
|
+
} else if (subtype === 'error_max_turns') {
|
|
318
|
+
lastError = 'Generation exceeded turn limit';
|
|
319
|
+
} else if (!lastError) {
|
|
320
|
+
lastError = subtype || 'unknown error';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
debug.error('engine', `[structured] result error: ${lastError}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
handleStreamError(error);
|
|
329
|
+
// handleStreamError swallows AbortError — if we reach here without throw, it was cancelled
|
|
330
|
+
throw new Error('Generation was cancelled');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (structuredOutput) {
|
|
334
|
+
return structuredOutput as T;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Fallback: parse the text result as JSON
|
|
338
|
+
if (resultText) {
|
|
339
|
+
try {
|
|
340
|
+
return JSON.parse(resultText) as T;
|
|
341
|
+
} catch {
|
|
342
|
+
debug.warn('engine', `[structured] result text is not valid JSON: ${resultText.slice(0, 200)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
throw new Error(lastError || 'Claude Code did not return valid structured output');
|
|
347
|
+
}
|
|
241
348
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { SDKMessage, SDKUserMessage, EngineSDKMessage } from '$shared/types/messaging';
|
|
13
|
-
import type { AIEngine, EngineQueryOptions } from '../../types';
|
|
13
|
+
import type { AIEngine, EngineQueryOptions, StructuredGenerationOptions } from '../../types';
|
|
14
14
|
import type { EngineModel } from '$shared/types/engine';
|
|
15
15
|
import type {
|
|
16
16
|
Provider,
|
|
@@ -1010,4 +1010,84 @@ export class OpenCodeEngine implements AIEngine {
|
|
|
1010
1010
|
|
|
1011
1011
|
return [{ type: 'text', text: '' }];
|
|
1012
1012
|
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* One-shot structured JSON generation.
|
|
1016
|
+
* Uses the v1 SDK client.session.prompt() (synchronous) with prompt
|
|
1017
|
+
* engineering for JSON output since v1 doesn't support format option.
|
|
1018
|
+
*/
|
|
1019
|
+
async generateStructured<T = unknown>(options: StructuredGenerationOptions): Promise<T> {
|
|
1020
|
+
const {
|
|
1021
|
+
prompt,
|
|
1022
|
+
model = 'claude-sonnet',
|
|
1023
|
+
schema,
|
|
1024
|
+
projectPath,
|
|
1025
|
+
abortController
|
|
1026
|
+
} = options;
|
|
1027
|
+
|
|
1028
|
+
if (!this._isInitialized) {
|
|
1029
|
+
await this.initialize();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const client = await ensureClient();
|
|
1033
|
+
|
|
1034
|
+
// Create a temporary session for this one-shot request
|
|
1035
|
+
const sessionResult = await client.session.create({
|
|
1036
|
+
query: { directory: projectPath }
|
|
1037
|
+
});
|
|
1038
|
+
const sessionId = sessionResult.data?.id;
|
|
1039
|
+
if (!sessionId) {
|
|
1040
|
+
throw new Error('Failed to create OpenCode session');
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Parse model into providerID/modelID
|
|
1044
|
+
const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : ['', model];
|
|
1045
|
+
|
|
1046
|
+
// Wrap prompt with JSON instruction since v1 doesn't support format option
|
|
1047
|
+
const jsonPrompt = `${prompt}
|
|
1048
|
+
|
|
1049
|
+
IMPORTANT: You MUST respond with ONLY a valid JSON object matching this schema, no other text:
|
|
1050
|
+
${JSON.stringify(schema, null, 2)}`;
|
|
1051
|
+
|
|
1052
|
+
debug.log('engine', `[OC structured] Sending prompt to session ${sessionId}, model=${model}`);
|
|
1053
|
+
|
|
1054
|
+
// Use v1 SDK synchronous prompt method — waits for completion
|
|
1055
|
+
const response = await client.session.prompt({
|
|
1056
|
+
path: { id: sessionId },
|
|
1057
|
+
body: {
|
|
1058
|
+
parts: [{ type: 'text', text: jsonPrompt }],
|
|
1059
|
+
...(providerID && modelID ? { model: { providerID, modelID } } : {}),
|
|
1060
|
+
tools: {}
|
|
1061
|
+
},
|
|
1062
|
+
query: { directory: projectPath },
|
|
1063
|
+
...(abortController?.signal && { signal: abortController.signal })
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
const data = response.data;
|
|
1067
|
+
if (!data) {
|
|
1068
|
+
throw new Error('OpenCode returned empty response');
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
debug.log('engine', `[OC structured] Got response with ${data.parts?.length || 0} parts`);
|
|
1072
|
+
|
|
1073
|
+
// Extract text content from response parts and parse as JSON
|
|
1074
|
+
const textParts = (data.parts || []).filter((p: any) => p.type === 'text');
|
|
1075
|
+
const fullText = textParts.map((p: any) => p.text || '').join('');
|
|
1076
|
+
|
|
1077
|
+
if (!fullText) {
|
|
1078
|
+
throw new Error('OpenCode returned no text content');
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
debug.log('engine', `[OC structured] Raw text: ${fullText.slice(0, 200)}`);
|
|
1082
|
+
|
|
1083
|
+
// Try to extract JSON from the response (may include markdown fences)
|
|
1084
|
+
const jsonMatch = fullText.match(/```(?:json)?\s*([\s\S]*?)```/) || fullText.match(/(\{[\s\S]*\})/);
|
|
1085
|
+
const jsonText = jsonMatch ? jsonMatch[1].trim() : fullText.trim();
|
|
1086
|
+
|
|
1087
|
+
try {
|
|
1088
|
+
return JSON.parse(jsonText) as T;
|
|
1089
|
+
} catch {
|
|
1090
|
+
throw new Error(`OpenCode did not return valid JSON: ${jsonText.slice(0, 200)}`);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1013
1093
|
}
|
package/backend/engine/types.ts
CHANGED
|
@@ -24,6 +24,16 @@ export interface EngineQueryOptions {
|
|
|
24
24
|
claudeAccountId?: number;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Options for one-shot structured generation (no tools, no streaming) */
|
|
28
|
+
export interface StructuredGenerationOptions {
|
|
29
|
+
prompt: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
schema: Record<string, unknown>;
|
|
32
|
+
projectPath: string;
|
|
33
|
+
abortController?: AbortController;
|
|
34
|
+
claudeAccountId?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
/** The contract every engine adapter must fulfil */
|
|
28
38
|
export interface AIEngine {
|
|
29
39
|
/** Engine identifier */
|
|
@@ -61,4 +71,11 @@ export interface AIEngine {
|
|
|
61
71
|
* Unblocks the canUseTool callback so the SDK can continue.
|
|
62
72
|
*/
|
|
63
73
|
resolveUserAnswer?(toolUseId: string, answers: Record<string, string>): boolean;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One-shot structured JSON generation (no tools, no streaming).
|
|
77
|
+
* Returns parsed JSON matching the provided schema.
|
|
78
|
+
* Optional — engines that don't support it leave undefined.
|
|
79
|
+
*/
|
|
80
|
+
generateStructured?<T = unknown>(options: StructuredGenerationOptions): Promise<T>;
|
|
64
81
|
}
|
|
@@ -317,7 +317,8 @@ export class GitService {
|
|
|
317
317
|
}
|
|
318
318
|
|
|
319
319
|
async fetch(cwd: string, remote = 'origin'): Promise<string> {
|
|
320
|
-
|
|
320
|
+
// Use explicit refspec to ensure all branches are fetched regardless of clone config
|
|
321
|
+
const result = await execGit(['fetch', remote, `+refs/heads/*:refs/remotes/${remote}/*`, '--prune'], cwd, 60000);
|
|
321
322
|
if (result.exitCode !== 0) {
|
|
322
323
|
throw new Error(`git fetch failed: ${result.stderr}`);
|
|
323
324
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Commit Message Generator Handler
|
|
3
|
+
*
|
|
4
|
+
* Uses AI engines to generate structured commit messages from staged diffs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { t } from 'elysia';
|
|
8
|
+
import { createRouter } from '$shared/utils/ws-server';
|
|
9
|
+
import { execGit } from '../../git/git-executor';
|
|
10
|
+
import { projectQueries } from '../../database/queries/project-queries';
|
|
11
|
+
import { initializeEngine } from '../../engine';
|
|
12
|
+
import { parseModelId } from '$shared/constants/engines';
|
|
13
|
+
import type { EngineType } from '$shared/types/engine';
|
|
14
|
+
import type { GeneratedCommitMessage } from '$shared/types/git';
|
|
15
|
+
import { debug } from '$shared/utils/logger';
|
|
16
|
+
|
|
17
|
+
const COMMIT_MESSAGE_SCHEMA = {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
type: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
enum: ['feat', 'fix', 'refactor', 'docs', 'test', 'chore', 'style', 'perf', 'ci', 'build'],
|
|
23
|
+
description: 'The conventional commit type'
|
|
24
|
+
},
|
|
25
|
+
scope: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Optional scope of the change (e.g., component name, module)'
|
|
28
|
+
},
|
|
29
|
+
subject: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Short imperative description, lowercase, no period, max 72 chars'
|
|
32
|
+
},
|
|
33
|
+
body: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'Optional longer description explaining the why behind the change'
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
required: ['type', 'subject']
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const commitMessageHandler = createRouter()
|
|
42
|
+
.http('git:generate-commit-message', {
|
|
43
|
+
data: t.Object({
|
|
44
|
+
projectId: t.String(),
|
|
45
|
+
engine: t.String(),
|
|
46
|
+
model: t.String(),
|
|
47
|
+
format: t.Union([t.Literal('single-line'), t.Literal('multi-line')])
|
|
48
|
+
}),
|
|
49
|
+
response: t.Object({
|
|
50
|
+
message: t.String()
|
|
51
|
+
})
|
|
52
|
+
}, async ({ data }) => {
|
|
53
|
+
const project = projectQueries.getById(data.projectId);
|
|
54
|
+
if (!project) throw new Error('Project not found');
|
|
55
|
+
|
|
56
|
+
// Get raw staged diff text
|
|
57
|
+
const diffResult = await execGit(['diff', '--cached'], project.path);
|
|
58
|
+
const rawDiff = diffResult.stdout;
|
|
59
|
+
|
|
60
|
+
if (!rawDiff.trim()) {
|
|
61
|
+
throw new Error('No staged changes to generate a commit message for');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const engineType = data.engine as EngineType;
|
|
65
|
+
const engine = await initializeEngine(engineType);
|
|
66
|
+
|
|
67
|
+
if (!engine.generateStructured) {
|
|
68
|
+
throw new Error(`Engine "${engineType}" does not support structured generation`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const formatInstruction = data.format === 'multi-line'
|
|
72
|
+
? 'Generate a multi-line conventional commit message with type, scope, subject, AND body fields. The body should explain WHY the change was made.'
|
|
73
|
+
: 'Generate a single-line conventional commit message with type, optional scope, and subject. Leave body empty.';
|
|
74
|
+
|
|
75
|
+
const prompt = `Analyze the following git diff and generate a conventional commit message.
|
|
76
|
+
|
|
77
|
+
Rules:
|
|
78
|
+
- type: one of feat, fix, refactor, docs, test, chore, style, perf, ci, build
|
|
79
|
+
- scope: optional, the area of the codebase affected (e.g., git, settings, engine)
|
|
80
|
+
- subject: imperative mood, lowercase, no period at end, max 72 characters
|
|
81
|
+
- ${formatInstruction}
|
|
82
|
+
|
|
83
|
+
Git diff:
|
|
84
|
+
${rawDiff}`;
|
|
85
|
+
|
|
86
|
+
// Parse model ID: "claude-code:haiku" → modelId "haiku", "opencode:anthropic/claude-sonnet" → modelId "anthropic/claude-sonnet"
|
|
87
|
+
const { modelId } = parseModelId(data.model);
|
|
88
|
+
|
|
89
|
+
debug.log('git', `Generating commit message via ${engineType}/${modelId}`);
|
|
90
|
+
|
|
91
|
+
const result = await engine.generateStructured<GeneratedCommitMessage>({
|
|
92
|
+
prompt,
|
|
93
|
+
model: modelId,
|
|
94
|
+
schema: COMMIT_MESSAGE_SCHEMA,
|
|
95
|
+
projectPath: project.path
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Format structured output into conventional commit string
|
|
99
|
+
const scopePart = result.scope ? `(${result.scope})` : '';
|
|
100
|
+
const subject = `${result.type}${scopePart}: ${result.subject}`;
|
|
101
|
+
|
|
102
|
+
let message = subject;
|
|
103
|
+
if (data.format === 'multi-line' && result.body) {
|
|
104
|
+
message = `${subject}\n\n${result.body}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { message };
|
|
108
|
+
});
|
package/backend/ws/git/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { branchHandler } from './branch';
|
|
|
12
12
|
import { logHandler } from './log';
|
|
13
13
|
import { remoteHandler } from './remote';
|
|
14
14
|
import { conflictHandler } from './conflict';
|
|
15
|
+
import { commitMessageHandler } from './commit-message';
|
|
15
16
|
|
|
16
17
|
export const gitRouter = createRouter()
|
|
17
18
|
.merge(statusHandler)
|
|
@@ -21,4 +22,5 @@ export const gitRouter = createRouter()
|
|
|
21
22
|
.merge(branchHandler)
|
|
22
23
|
.merge(logHandler)
|
|
23
24
|
.merge(remoteHandler)
|
|
24
|
-
.merge(conflictHandler)
|
|
25
|
+
.merge(conflictHandler)
|
|
26
|
+
.merge(commitMessageHandler);
|
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { createRouter } from '$shared/utils/ws-server';
|
|
11
|
+
import { t } from 'elysia';
|
|
11
12
|
import { operationsHandler } from './operations';
|
|
12
13
|
|
|
13
14
|
export const systemRouter = createRouter()
|
|
14
|
-
.merge(operationsHandler)
|
|
15
|
+
.merge(operationsHandler)
|
|
16
|
+
// Declare system:update-completed event (broadcast after successful package update)
|
|
17
|
+
.emit('system:update-completed', t.Object({
|
|
18
|
+
fromVersion: t.String(),
|
|
19
|
+
toVersion: t.String()
|
|
20
|
+
}));
|
|
@@ -13,6 +13,10 @@ import { readFileSync } from 'node:fs';
|
|
|
13
13
|
import { createRouter } from '$shared/utils/ws-server';
|
|
14
14
|
import { initializeDatabase, getDatabase } from '../../database';
|
|
15
15
|
import { debug } from '$shared/utils/logger';
|
|
16
|
+
import { ws } from '$backend/utils/ws';
|
|
17
|
+
|
|
18
|
+
/** In-memory flag: set after successful update, cleared on server restart */
|
|
19
|
+
let pendingUpdate: { fromVersion: string; toVersion: string } | null = null;
|
|
16
20
|
|
|
17
21
|
/** Read current version from package.json */
|
|
18
22
|
function getCurrentVersion(): string {
|
|
@@ -56,7 +60,12 @@ export const operationsHandler = createRouter()
|
|
|
56
60
|
response: t.Object({
|
|
57
61
|
currentVersion: t.String(),
|
|
58
62
|
latestVersion: t.String(),
|
|
59
|
-
updateAvailable: t.Boolean()
|
|
63
|
+
updateAvailable: t.Boolean(),
|
|
64
|
+
pendingRestart: t.Boolean(),
|
|
65
|
+
pendingUpdate: t.Optional(t.Object({
|
|
66
|
+
fromVersion: t.String(),
|
|
67
|
+
toVersion: t.String()
|
|
68
|
+
}))
|
|
60
69
|
})
|
|
61
70
|
}, async () => {
|
|
62
71
|
const currentVersion = getCurrentVersion();
|
|
@@ -67,7 +76,13 @@ export const operationsHandler = createRouter()
|
|
|
67
76
|
|
|
68
77
|
debug.log('server', `Latest version: ${latestVersion}, update available: ${updateAvailable}`);
|
|
69
78
|
|
|
70
|
-
return {
|
|
79
|
+
return {
|
|
80
|
+
currentVersion,
|
|
81
|
+
latestVersion,
|
|
82
|
+
updateAvailable,
|
|
83
|
+
pendingRestart: pendingUpdate !== null,
|
|
84
|
+
pendingUpdate: pendingUpdate ?? undefined
|
|
85
|
+
};
|
|
71
86
|
})
|
|
72
87
|
|
|
73
88
|
// Run package update
|
|
@@ -98,9 +113,20 @@ export const operationsHandler = createRouter()
|
|
|
98
113
|
throw new Error(`Update failed (exit code ${exitCode}): ${output}`);
|
|
99
114
|
}
|
|
100
115
|
|
|
116
|
+
const fromVersion = getCurrentVersion();
|
|
117
|
+
|
|
101
118
|
// Re-fetch to confirm new version
|
|
102
119
|
const newVersion = await fetchLatestVersion();
|
|
103
120
|
|
|
121
|
+
// Set pending restart flag (persists until server restarts)
|
|
122
|
+
pendingUpdate = { fromVersion, toVersion: newVersion };
|
|
123
|
+
|
|
124
|
+
// Broadcast to all connected clients
|
|
125
|
+
ws.emit.global('system:update-completed', {
|
|
126
|
+
fromVersion,
|
|
127
|
+
toVersion: newVersion
|
|
128
|
+
});
|
|
129
|
+
|
|
104
130
|
debug.log('server', `Update completed. New version: ${newVersion}`);
|
|
105
131
|
|
|
106
132
|
return { success: true, output, newVersion };
|
package/frontend/App.svelte
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import WorkspaceLayout from '$frontend/components/workspace/WorkspaceLayout.svelte';
|
|
5
5
|
import ConnectionBanner from '$frontend/components/common/feedback/ConnectionBanner.svelte';
|
|
6
6
|
import UpdateBanner from '$frontend/components/common/feedback/UpdateBanner.svelte';
|
|
7
|
+
import RestartRequiredModal from '$frontend/components/common/feedback/RestartRequiredModal.svelte';
|
|
7
8
|
import LoadingScreen from '$frontend/components/common/feedback/LoadingScreen.svelte';
|
|
8
9
|
import SetupPage from '$frontend/components/auth/SetupPage.svelte';
|
|
9
10
|
import LoginPage from '$frontend/components/auth/LoginPage.svelte';
|
|
@@ -70,4 +71,6 @@
|
|
|
70
71
|
</WorkspaceLayout>
|
|
71
72
|
</div>
|
|
72
73
|
</div>
|
|
74
|
+
|
|
75
|
+
<RestartRequiredModal />
|
|
73
76
|
{/if}
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
const stepLabels: Record<WizardStep, { label: string; icon: IconName }> = {
|
|
88
88
|
'auth-mode': { label: 'Login', icon: 'lucide:shield' },
|
|
89
89
|
'admin-account': { label: 'Account', icon: 'lucide:user-plus' },
|
|
90
|
-
'engines': { label: 'Engines', icon: 'lucide:
|
|
90
|
+
'engines': { label: 'Engines', icon: 'lucide:plug' },
|
|
91
91
|
'preferences': { label: 'Preferences', icon: 'lucide:palette' }
|
|
92
92
|
};
|
|
93
93
|
|
|
@@ -602,7 +602,7 @@
|
|
|
602
602
|
{:else if currentStep === 'engines'}
|
|
603
603
|
<div class="space-y-4">
|
|
604
604
|
<div class="text-center">
|
|
605
|
-
<h2 class="text-base font-semibold text-slate-900 dark:text-slate-100 mb-1">
|
|
605
|
+
<h2 class="text-base font-semibold text-slate-900 dark:text-slate-100 mb-1">Engines</h2>
|
|
606
606
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
|
607
607
|
Check your AI engine installations.
|
|
608
608
|
</p>
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
|
|
142
142
|
const chatPlaceholder = $derived.by(() => {
|
|
143
143
|
if (chatBlockedReason === 'no-claude-account') {
|
|
144
|
-
return 'No Claude Code account connected. Configure it in Settings →
|
|
144
|
+
return 'No Claude Code account connected. Configure it in Settings → Engines → Claude Code → Accounts.';
|
|
145
145
|
}
|
|
146
146
|
if (chatBlockedReason === 'no-model') {
|
|
147
147
|
return 'No model selected. Please select a model to start chatting.';
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
import TokenUsageModal from '../modal/TokenUsageModal.svelte';
|
|
25
25
|
import DebugModal from '../modal/DebugModal.svelte';
|
|
26
26
|
import Dialog from '$frontend/components/common/overlay/Dialog.svelte';
|
|
27
|
-
import
|
|
27
|
+
import ConflictResolutionModal from '$frontend/components/checkpoint/ConflictResolutionModal.svelte';
|
|
28
|
+
import { snapshotService } from '$frontend/services/snapshot/snapshot.service';
|
|
29
|
+
import type { RestoreConflict, ConflictResolution } from '$frontend/services/snapshot/snapshot.service';
|
|
28
30
|
|
|
29
31
|
const {
|
|
30
32
|
message,
|
|
@@ -39,6 +41,11 @@
|
|
|
39
41
|
let showTokenUsagePopup = $state(false);
|
|
40
42
|
let showRestoreConfirm = $state(false);
|
|
41
43
|
|
|
44
|
+
// Conflict resolution state
|
|
45
|
+
let showConflictModal = $state(false);
|
|
46
|
+
let conflictList = $state<RestoreConflict[]>([]);
|
|
47
|
+
let processingRestore = $state(false);
|
|
48
|
+
|
|
42
49
|
// Format timestamp
|
|
43
50
|
const formatTime = (timestamp?: string) => {
|
|
44
51
|
if (!timestamp) return 'Unknown';
|
|
@@ -268,15 +275,8 @@
|
|
|
268
275
|
showDebugPopup = false;
|
|
269
276
|
}
|
|
270
277
|
|
|
271
|
-
// Handle restore button click
|
|
278
|
+
// Handle restore button click - check conflicts first
|
|
272
279
|
async function handleRestore() {
|
|
273
|
-
showRestoreConfirm = true;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Confirm restore action
|
|
277
|
-
async function confirmRestore() {
|
|
278
|
-
showRestoreConfirm = false;
|
|
279
|
-
|
|
280
280
|
if (!messageId) {
|
|
281
281
|
addNotification({
|
|
282
282
|
type: 'error',
|
|
@@ -287,16 +287,41 @@
|
|
|
287
287
|
return;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
const currentSessionId = sessionState.currentSession?.id;
|
|
291
|
+
if (!currentSessionId) return;
|
|
292
|
+
|
|
290
293
|
try {
|
|
291
|
-
|
|
292
|
-
|
|
294
|
+
const conflictCheck = await snapshotService.checkConflicts(messageId, currentSessionId);
|
|
295
|
+
|
|
296
|
+
if (conflictCheck.hasConflicts) {
|
|
297
|
+
// Show conflict resolution modal
|
|
298
|
+
conflictList = conflictCheck.conflicts;
|
|
299
|
+
showConflictModal = true;
|
|
300
|
+
} else {
|
|
301
|
+
// No conflicts - show simple confirmation
|
|
302
|
+
showRestoreConfirm = true;
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
debug.error('chat', 'Error checking conflicts:', error);
|
|
306
|
+
// Fallback to simple confirmation
|
|
307
|
+
showRestoreConfirm = true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Execute restore with optional conflict resolutions
|
|
312
|
+
async function executeRestore(resolutions?: ConflictResolution) {
|
|
313
|
+
if (!messageId || !sessionState.currentSession?.id) return;
|
|
314
|
+
|
|
315
|
+
processingRestore = true;
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
await snapshotService.restore(
|
|
293
319
|
messageId,
|
|
294
|
-
|
|
295
|
-
|
|
320
|
+
sessionState.currentSession.id,
|
|
321
|
+
resolutions
|
|
322
|
+
);
|
|
296
323
|
|
|
297
|
-
|
|
298
|
-
await loadMessagesForSession(sessionState.currentSession.id);
|
|
299
|
-
}
|
|
324
|
+
await loadMessagesForSession(sessionState.currentSession.id);
|
|
300
325
|
} catch (error) {
|
|
301
326
|
debug.error('chat', 'Restore error:', error);
|
|
302
327
|
addNotification({
|
|
@@ -305,9 +330,22 @@
|
|
|
305
330
|
message: error instanceof Error ? error.message : 'Unknown error',
|
|
306
331
|
duration: 5000
|
|
307
332
|
});
|
|
333
|
+
} finally {
|
|
334
|
+
processingRestore = false;
|
|
308
335
|
}
|
|
309
336
|
}
|
|
310
337
|
|
|
338
|
+
// Confirm simple restore (no conflicts)
|
|
339
|
+
async function confirmRestore() {
|
|
340
|
+
showRestoreConfirm = false;
|
|
341
|
+
await executeRestore();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Confirm restore with conflict resolutions
|
|
345
|
+
async function confirmConflictRestore(resolutions: ConflictResolution) {
|
|
346
|
+
await executeRestore(resolutions);
|
|
347
|
+
}
|
|
348
|
+
|
|
311
349
|
// Handle edit button click
|
|
312
350
|
async function handleEdit() {
|
|
313
351
|
if (!messageId) {
|
|
@@ -473,6 +511,16 @@ This will restore your conversation to this point.`}
|
|
|
473
511
|
}}
|
|
474
512
|
/>
|
|
475
513
|
|
|
514
|
+
<!-- Conflict Resolution Modal -->
|
|
515
|
+
<ConflictResolutionModal
|
|
516
|
+
bind:isOpen={showConflictModal}
|
|
517
|
+
conflicts={conflictList}
|
|
518
|
+
onConfirm={confirmConflictRestore}
|
|
519
|
+
onClose={() => {
|
|
520
|
+
conflictList = [];
|
|
521
|
+
}}
|
|
522
|
+
/>
|
|
523
|
+
|
|
476
524
|
<style>
|
|
477
525
|
/* Animate chevron icon when details are opened */
|
|
478
526
|
:global(details[open] summary .iconify) {
|