@myrialabs/clopen 0.2.3 → 0.2.5
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/message-converter.ts +37 -2
- 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/backend/ws/user/crud.ts +6 -3
- 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/chat/tools/components/FileHeader.svelte +19 -5
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
- 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/common/media/MediaPreview.svelte +187 -0
- package/frontend/components/files/FileViewer.svelte +11 -143
- package/frontend/components/git/BranchManager.svelte +143 -155
- package/frontend/components/git/CommitForm.svelte +61 -11
- package/frontend/components/git/DiffViewer.svelte +50 -130
- package/frontend/components/git/FileChangeItem.svelte +22 -0
- 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/DesktopNavigator.svelte +27 -1
- package/frontend/components/workspace/PanelHeader.svelte +1 -3
- package/frontend/components/workspace/WorkspaceLayout.svelte +14 -2
- package/frontend/components/workspace/panels/FilesPanel.svelte +76 -1
- package/frontend/components/workspace/panels/GitPanel.svelte +84 -33
- package/frontend/main.ts +4 -0
- package/frontend/stores/core/files.svelte.ts +15 -1
- package/frontend/stores/features/settings.svelte.ts +13 -2
- package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
- package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
- package/frontend/stores/ui/update.svelte.ts +45 -4
- package/frontend/utils/file-type.ts +68 -0
- package/index.html +1 -0
- package/package.json +1 -1
- package/shared/constants/binary-extensions.ts +40 -0
- package/shared/types/git.ts +15 -0
- package/shared/types/messaging/tool.ts +1 -0
- package/shared/types/stores/settings.ts +12 -0
- package/shared/utils/file-type-detection.ts +9 -1
- package/static/manifest.json +16 -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
|
}
|
|
@@ -356,6 +356,35 @@ function normalizeToolInput(claudeToolName: string, raw: OCToolInput): Normalize
|
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
// ============================================================
|
|
360
|
+
// Tool Error Detection
|
|
361
|
+
// ============================================================
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Common error prefixes in tool output content.
|
|
365
|
+
* OpenCode SDK may mark a tool as 'completed' even when the output is an error
|
|
366
|
+
* (e.g. "Error: File not found"). These patterns detect such cases.
|
|
367
|
+
*/
|
|
368
|
+
const ERROR_CONTENT_PATTERNS = [
|
|
369
|
+
/^Error:\s/i,
|
|
370
|
+
/^ENOENT:\s/i,
|
|
371
|
+
/^EPERM:\s/i,
|
|
372
|
+
/^EACCES:\s/i,
|
|
373
|
+
/^Command failed/i,
|
|
374
|
+
/^Permission denied/i,
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Determine if a tool result should be marked as is_error.
|
|
379
|
+
* Returns true when the tool part status is 'error', OR when the output
|
|
380
|
+
* content matches a known error pattern (for tools that complete with error output).
|
|
381
|
+
*/
|
|
382
|
+
function isToolError(status: string, content: string): boolean {
|
|
383
|
+
if (status === 'error') return true;
|
|
384
|
+
if (!content || status !== 'completed') return false;
|
|
385
|
+
return ERROR_CONTENT_PATTERNS.some(pattern => pattern.test(content));
|
|
386
|
+
}
|
|
387
|
+
|
|
359
388
|
// ============================================================
|
|
360
389
|
// Stop Reason Mapping
|
|
361
390
|
// ============================================================
|
|
@@ -483,16 +512,19 @@ export function convertAssistantMessages(
|
|
|
483
512
|
};
|
|
484
513
|
|
|
485
514
|
if (toolPart.state.status === 'completed') {
|
|
515
|
+
const output = toolPart.state.output || '';
|
|
486
516
|
block.$result = {
|
|
487
517
|
type: 'tool_result',
|
|
488
518
|
tool_use_id: block.id,
|
|
489
|
-
content:
|
|
519
|
+
content: output,
|
|
520
|
+
...(isToolError('completed', output) && { is_error: true }),
|
|
490
521
|
};
|
|
491
522
|
} else if (toolPart.state.status === 'error') {
|
|
492
523
|
block.$result = {
|
|
493
524
|
type: 'tool_result',
|
|
494
525
|
tool_use_id: block.id,
|
|
495
526
|
content: toolPart.state.error || 'Tool execution failed',
|
|
527
|
+
is_error: true,
|
|
496
528
|
};
|
|
497
529
|
}
|
|
498
530
|
|
|
@@ -854,6 +886,8 @@ export function convertToolResultOnly(
|
|
|
854
886
|
content = '';
|
|
855
887
|
}
|
|
856
888
|
|
|
889
|
+
const hasError = isToolError(toolPart.state.status, content);
|
|
890
|
+
|
|
857
891
|
return {
|
|
858
892
|
type: 'user',
|
|
859
893
|
uuid: crypto.randomUUID(),
|
|
@@ -864,7 +898,8 @@ export function convertToolResultOnly(
|
|
|
864
898
|
content: [{
|
|
865
899
|
type: 'tool_result',
|
|
866
900
|
tool_use_id: toolUseId,
|
|
867
|
-
content
|
|
901
|
+
content,
|
|
902
|
+
...(hasError && { is_error: true }),
|
|
868
903
|
}]
|
|
869
904
|
}
|
|
870
905
|
} as unknown as SDKMessage;
|
|
@@ -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/backend/ws/user/crud.ts
CHANGED
|
@@ -109,7 +109,8 @@ export const crudHandler = createRouter()
|
|
|
109
109
|
currentProjectId: t.Union([t.String(), t.Null()]),
|
|
110
110
|
lastView: t.Union([t.String(), t.Null()]),
|
|
111
111
|
settings: t.Union([t.Any(), t.Null()]),
|
|
112
|
-
unreadSessions: t.Union([t.Any(), t.Null()])
|
|
112
|
+
unreadSessions: t.Union([t.Any(), t.Null()]),
|
|
113
|
+
todoPanelState: t.Union([t.Any(), t.Null()])
|
|
113
114
|
})
|
|
114
115
|
}, async ({ conn }) => {
|
|
115
116
|
const userId = ws.getUserId(conn);
|
|
@@ -118,6 +119,7 @@ export const crudHandler = createRouter()
|
|
|
118
119
|
const lastView = getUserState(userId, 'lastView') as string | null;
|
|
119
120
|
const userSettings = getUserState(userId, 'settings');
|
|
120
121
|
const unreadSessions = getUserState(userId, 'unreadSessions');
|
|
122
|
+
const todoPanelState = getUserState(userId, 'todoPanelState');
|
|
121
123
|
|
|
122
124
|
debug.log('user', `Restored state for ${userId}:`, {
|
|
123
125
|
currentProjectId,
|
|
@@ -130,7 +132,8 @@ export const crudHandler = createRouter()
|
|
|
130
132
|
currentProjectId: currentProjectId ?? null,
|
|
131
133
|
lastView: lastView ?? null,
|
|
132
134
|
settings: userSettings ?? null,
|
|
133
|
-
unreadSessions: unreadSessions ?? null
|
|
135
|
+
unreadSessions: unreadSessions ?? null,
|
|
136
|
+
todoPanelState: todoPanelState ?? null
|
|
134
137
|
};
|
|
135
138
|
})
|
|
136
139
|
|
|
@@ -147,7 +150,7 @@ export const crudHandler = createRouter()
|
|
|
147
150
|
const userId = ws.getUserId(conn);
|
|
148
151
|
|
|
149
152
|
// Validate allowed keys to prevent arbitrary data storage
|
|
150
|
-
const allowedKeys = ['currentProjectId', 'lastView', 'settings', 'unreadSessions'];
|
|
153
|
+
const allowedKeys = ['currentProjectId', 'lastView', 'settings', 'unreadSessions', 'todoPanelState'];
|
|
151
154
|
if (!allowedKeys.includes(data.key)) {
|
|
152
155
|
throw new Error(`Invalid state key: ${data.key}. Allowed: ${allowedKeys.join(', ')}`);
|
|
153
156
|
}
|
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.';
|