mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 13:47:12 +02:00
feat: scaffold electron orchestrator foundation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
import { dialog } from 'electron';
|
||||
|
||||
import type { TurnDeltaEvent } from '@shared/contracts/sidecar';
|
||||
import { buildSessionTitle, validatePatternDefinition, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { createId, nowIso } from '@shared/utils/ids';
|
||||
|
||||
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
||||
import { SecretStore } from '@main/secrets/secretStore';
|
||||
import { SidecarClient } from '@main/sidecar/sidecarProcess';
|
||||
|
||||
type AppServiceEvents = {
|
||||
'workspace-updated': [WorkspaceState];
|
||||
'session-event': [SessionEventRecord];
|
||||
};
|
||||
|
||||
function isBuiltinPattern(patternId: string): boolean {
|
||||
return patternId.startsWith('pattern-');
|
||||
}
|
||||
|
||||
export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly workspaceRepository = new WorkspaceRepository();
|
||||
private readonly sidecar = new SidecarClient();
|
||||
private readonly secretStore = new SecretStore();
|
||||
private workspace?: WorkspaceState;
|
||||
|
||||
async loadWorkspace(): Promise<WorkspaceState> {
|
||||
if (!this.workspace) {
|
||||
this.workspace = await this.workspaceRepository.load();
|
||||
}
|
||||
|
||||
return this.workspace;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.sidecar.dispose();
|
||||
void this.secretStore;
|
||||
}
|
||||
|
||||
async addProject(): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Open project folder',
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
const folderPath = result.filePaths[0];
|
||||
const existing = workspace.projects.find((project) => project.path === folderPath);
|
||||
if (existing) {
|
||||
workspace.selectedProjectId = existing.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
const project: ProjectRecord = {
|
||||
id: createId('project'),
|
||||
name: basename(folderPath),
|
||||
path: folderPath,
|
||||
addedAt: nowIso(),
|
||||
};
|
||||
|
||||
workspace.projects.push(project);
|
||||
workspace.selectedProjectId = project.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async removeProject(projectId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.projects = workspace.projects.filter((project) => project.id !== projectId);
|
||||
workspace.sessions = workspace.sessions.filter((session) => session.projectId !== projectId);
|
||||
|
||||
if (workspace.selectedProjectId === projectId) {
|
||||
workspace.selectedProjectId = workspace.projects[0]?.id;
|
||||
}
|
||||
|
||||
if (
|
||||
workspace.selectedSessionId &&
|
||||
!workspace.sessions.some((session) => session.id === workspace.selectedSessionId)
|
||||
) {
|
||||
workspace.selectedSessionId = undefined;
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const issues = validatePatternDefinition(pattern).filter((issue) => issue.level === 'error');
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues[0].message);
|
||||
}
|
||||
|
||||
const existingIndex = workspace.patterns.findIndex((current) => current.id === pattern.id);
|
||||
const candidate: PatternDefinition = {
|
||||
...pattern,
|
||||
createdAt: existingIndex >= 0 ? workspace.patterns[existingIndex].createdAt : nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
workspace.patterns[existingIndex] = candidate;
|
||||
} else {
|
||||
workspace.patterns.push(candidate);
|
||||
}
|
||||
|
||||
workspace.selectedPatternId = candidate.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async deletePattern(patternId: string): Promise<WorkspaceState> {
|
||||
if (isBuiltinPattern(patternId)) {
|
||||
throw new Error('Built-in patterns cannot be deleted.');
|
||||
}
|
||||
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.patterns = workspace.patterns.filter((pattern) => pattern.id !== patternId);
|
||||
|
||||
if (workspace.selectedPatternId === patternId) {
|
||||
workspace.selectedPatternId = workspace.patterns[0]?.id;
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async createSession(projectId: string, patternId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
const pattern = this.requirePattern(workspace, patternId);
|
||||
|
||||
const session: SessionRecord = {
|
||||
id: createId('session'),
|
||||
projectId: project.id,
|
||||
patternId: pattern.id,
|
||||
title: pattern.name,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
status: 'idle',
|
||||
messages: [],
|
||||
};
|
||||
|
||||
workspace.sessions.unshift(session);
|
||||
workspace.selectedProjectId = project.id;
|
||||
workspace.selectedPatternId = pattern.id;
|
||||
workspace.selectedSessionId = session.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async sendSessionMessage(sessionId: string, content: string): Promise<void> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.messages.push({
|
||||
id: createId('msg'),
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: trimmed,
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
session.title = buildSessionTitle(pattern, session.messages);
|
||||
session.status = 'running';
|
||||
session.lastError = undefined;
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
this.emitSessionEvent({
|
||||
sessionId: session.id,
|
||||
kind: 'status',
|
||||
status: 'running',
|
||||
occurredAt: nowIso(),
|
||||
});
|
||||
|
||||
const requestId = createId('turn');
|
||||
try {
|
||||
const responseMessages = await this.sidecar.runTurn(
|
||||
{
|
||||
type: 'run-turn',
|
||||
requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: project.path,
|
||||
pattern,
|
||||
messages: session.messages,
|
||||
},
|
||||
async (event) => {
|
||||
await this.applyTurnDelta(workspace, session.id, event);
|
||||
},
|
||||
);
|
||||
|
||||
this.finalizeTurn(workspace, session.id, responseMessages);
|
||||
await this.persistAndBroadcast(workspace);
|
||||
} catch (error) {
|
||||
session.status = 'error';
|
||||
session.lastError = error instanceof Error ? error.message : String(error);
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId: session.id,
|
||||
kind: 'error',
|
||||
occurredAt: nowIso(),
|
||||
error: session.lastError,
|
||||
});
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
async selectProject(projectId?: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.selectedProjectId = projectId;
|
||||
workspace.selectedSessionId = workspace.selectedSessionId;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async selectPattern(patternId?: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.selectedPatternId = patternId;
|
||||
workspace.selectedSessionId = workspace.selectedSessionId;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async selectSession(sessionId?: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.selectedSessionId = sessionId;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
private requireProject(workspace: WorkspaceState, projectId: string): ProjectRecord {
|
||||
const project = workspace.projects.find((current) => current.id === projectId);
|
||||
if (!project) {
|
||||
throw new Error(`Project "${projectId}" was not found.`);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
private requirePattern(workspace: WorkspaceState, patternId: string): PatternDefinition {
|
||||
const pattern = workspace.patterns.find((current) => current.id === patternId);
|
||||
if (!pattern) {
|
||||
throw new Error(`Pattern "${patternId}" was not found.`);
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord {
|
||||
const session = workspace.sessions.find((current) => current.id === sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session "${sessionId}" was not found.`);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private async applyTurnDelta(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: TurnDeltaEvent,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const existing = session.messages.find((message) => message.id === event.messageId);
|
||||
|
||||
if (existing) {
|
||||
existing.content += event.contentDelta;
|
||||
existing.pending = true;
|
||||
} else {
|
||||
session.messages.push({
|
||||
id: event.messageId,
|
||||
role: 'assistant',
|
||||
authorName: event.authorName,
|
||||
content: event.contentDelta,
|
||||
createdAt: nowIso(),
|
||||
pending: true,
|
||||
});
|
||||
}
|
||||
|
||||
session.updatedAt = nowIso();
|
||||
await this.workspaceRepository.save(workspace);
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'message-delta',
|
||||
occurredAt: nowIso(),
|
||||
messageId: event.messageId,
|
||||
authorName: event.authorName,
|
||||
contentDelta: event.contentDelta,
|
||||
});
|
||||
}
|
||||
|
||||
private finalizeTurn(workspace: WorkspaceState, sessionId: string, messages: ChatMessageRecord[]): void {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const incomingIds = new Set(messages.map((message) => message.id));
|
||||
|
||||
for (const message of messages) {
|
||||
const existing = session.messages.find((current) => current.id === message.id);
|
||||
if (existing) {
|
||||
existing.authorName = message.authorName;
|
||||
existing.content = message.content;
|
||||
existing.pending = false;
|
||||
} else {
|
||||
session.messages.push({ ...message, pending: false });
|
||||
}
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'message-complete',
|
||||
occurredAt: nowIso(),
|
||||
messageId: message.id,
|
||||
authorName: message.authorName,
|
||||
});
|
||||
}
|
||||
|
||||
for (const message of session.messages) {
|
||||
if (message.pending && incomingIds.has(message.id)) {
|
||||
message.pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
session.status = 'idle';
|
||||
session.lastError = undefined;
|
||||
session.updatedAt = nowIso();
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'status',
|
||||
occurredAt: nowIso(),
|
||||
status: 'idle',
|
||||
});
|
||||
}
|
||||
|
||||
private async persistAndBroadcast(workspace: WorkspaceState): Promise<WorkspaceState> {
|
||||
await this.workspaceRepository.save(workspace);
|
||||
this.emit('workspace-updated', workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private emitSessionEvent(event: SessionEventRecord): void {
|
||||
this.emit('session-event', event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
|
||||
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
|
||||
import { KopayaAppService } from '@main/KopayaAppService';
|
||||
import { createMainWindow } from '@main/windows/createMainWindow';
|
||||
|
||||
let mainWindow: BrowserWindow | undefined;
|
||||
let appService: KopayaAppService | undefined;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
appService = new KopayaAppService();
|
||||
|
||||
mainWindow = createMainWindow();
|
||||
registerIpcHandlers(mainWindow, appService);
|
||||
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(bootstrap);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await bootstrap();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
await appService?.dispose();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
|
||||
import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type { CreateSessionInput, SavePatternInput, SendSessionMessageInput } from '@shared/contracts/ipc';
|
||||
|
||||
import { KopayaAppService } from '@main/KopayaAppService';
|
||||
|
||||
export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppService): void {
|
||||
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
||||
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
|
||||
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
|
||||
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
|
||||
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
||||
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
||||
service.createSession(input.projectId, input.patternId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
||||
service.sendSessionMessage(input.sessionId, input.content),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
|
||||
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
|
||||
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
|
||||
|
||||
service.on('workspace-updated', (workspace) => {
|
||||
window.webContents.send(ipcChannels.workspaceUpdated, workspace);
|
||||
});
|
||||
|
||||
service.on('session-event', (event) => {
|
||||
window.webContents.send(ipcChannels.sessionEvent, event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { app } from 'electron';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function getWorkspaceFilePath(): string {
|
||||
return join(app.getPath('userData'), 'workspace.json');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
|
||||
try {
|
||||
const contents = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(contents) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFile<T>(filePath: string, value: T): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
import { getWorkspaceFilePath } from '@main/persistence/appPaths';
|
||||
import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
|
||||
|
||||
function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] {
|
||||
const builtinTimestamp = nowIso();
|
||||
const builtinPatterns = createBuiltinPatterns(builtinTimestamp);
|
||||
const builtinIds = new Set(builtinPatterns.map((pattern) => pattern.id));
|
||||
const existingMap = new Map(existingPatterns.map((pattern) => [pattern.id, pattern]));
|
||||
|
||||
const mergedBuiltins = builtinPatterns.map((builtin) => {
|
||||
const existing = existingMap.get(builtin.id);
|
||||
if (!existing) {
|
||||
return builtin;
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
availability: builtin.availability,
|
||||
unavailabilityReason: builtin.unavailabilityReason,
|
||||
mode: builtin.mode,
|
||||
};
|
||||
});
|
||||
|
||||
const customPatterns = existingPatterns.filter((pattern) => !builtinIds.has(pattern.id));
|
||||
return [...mergedBuiltins, ...customPatterns];
|
||||
}
|
||||
|
||||
export class WorkspaceRepository {
|
||||
readonly filePath = getWorkspaceFilePath();
|
||||
|
||||
async load(): Promise<WorkspaceState> {
|
||||
const stored = await readJsonFile<WorkspaceState>(this.filePath);
|
||||
if (!stored) {
|
||||
const seeded = createWorkspaceSeed();
|
||||
await this.save(seeded);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
const workspace: WorkspaceState = {
|
||||
...stored,
|
||||
patterns: mergePatterns(stored.patterns ?? []),
|
||||
projects: stored.projects ?? [],
|
||||
sessions: stored.sessions ?? [],
|
||||
lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(),
|
||||
};
|
||||
|
||||
await this.save(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async save(workspace: WorkspaceState): Promise<void> {
|
||||
await writeJsonFile(this.filePath, {
|
||||
...workspace,
|
||||
lastUpdatedAt: nowIso(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import keytar from 'keytar';
|
||||
|
||||
const serviceName = 'kopaya';
|
||||
|
||||
export class SecretStore {
|
||||
async get(account: string): Promise<string | null> {
|
||||
return keytar.getPassword(serviceName, account);
|
||||
}
|
||||
|
||||
async set(account: string, secret: string): Promise<void> {
|
||||
await keytar.setPassword(serviceName, account, secret);
|
||||
}
|
||||
|
||||
async delete(account: string): Promise<boolean> {
|
||||
return keytar.deletePassword(serviceName, account);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { app } from 'electron';
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type {
|
||||
SidecarCapabilities,
|
||||
SidecarCommand,
|
||||
SidecarEvent,
|
||||
TurnDeltaEvent,
|
||||
ValidatePatternCommand,
|
||||
RunTurnCommand,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
type PendingCommand =
|
||||
| {
|
||||
kind: 'capabilities';
|
||||
resolve: (capabilities: SidecarCapabilities) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
| {
|
||||
kind: 'validate-pattern';
|
||||
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
| {
|
||||
kind: 'run-turn';
|
||||
resolve: (messages: ChatMessageRecord[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
onDelta: (event: TurnDeltaEvent) => void;
|
||||
};
|
||||
|
||||
function getProjectRoot(): string {
|
||||
return app.getAppPath();
|
||||
}
|
||||
|
||||
function resolveSidecarProcess(): { command: string; args: string[] } {
|
||||
if (app.isPackaged) {
|
||||
return {
|
||||
command: join(process.resourcesPath, 'sidecar', 'Kopaya.AgentHost.exe'),
|
||||
args: ['--stdio'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'dotnet',
|
||||
args: [
|
||||
'run',
|
||||
'--project',
|
||||
join(getProjectRoot(), 'sidecar', 'src', 'Kopaya.AgentHost', 'Kopaya.AgentHost.csproj'),
|
||||
'--',
|
||||
'--stdio',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export class SidecarClient {
|
||||
private process?: ChildProcessWithoutNullStreams;
|
||||
private stdoutBuffer = '';
|
||||
private readonly pending = new Map<string, PendingCommand>();
|
||||
|
||||
async describeCapabilities(): Promise<SidecarCapabilities> {
|
||||
const command = await this.dispatch<SidecarCapabilities>({
|
||||
type: 'describe-capabilities',
|
||||
requestId: `cap-${Date.now()}`,
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
async validatePattern(pattern: ValidatePatternCommand['pattern']): Promise<unknown> {
|
||||
return this.dispatch<unknown>({
|
||||
type: 'validate-pattern',
|
||||
requestId: `validate-${Date.now()}`,
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
|
||||
async runTurn(command: RunTurnCommand, onDelta: (event: TurnDeltaEvent) => void): Promise<ChatMessageRecord[]> {
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (!this.process) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.process.kill();
|
||||
this.process = undefined;
|
||||
}
|
||||
|
||||
private async ensureProcess(): Promise<ChildProcessWithoutNullStreams> {
|
||||
if (this.process && !this.process.killed) {
|
||||
return this.process;
|
||||
}
|
||||
|
||||
const sidecar = resolveSidecarProcess();
|
||||
this.process = spawn(sidecar.command, sidecar.args, {
|
||||
cwd: getProjectRoot(),
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.process.stdout.setEncoding('utf8');
|
||||
this.process.stdout.on('data', (chunk: string) => {
|
||||
this.stdoutBuffer += chunk;
|
||||
this.flushStdoutBuffer();
|
||||
});
|
||||
|
||||
this.process.stderr.setEncoding('utf8');
|
||||
this.process.stderr.on('data', (chunk: string) => {
|
||||
console.error('[kopaya sidecar]', chunk.trim());
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
const error = new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`);
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.process = undefined;
|
||||
this.stdoutBuffer = '';
|
||||
});
|
||||
|
||||
return this.process;
|
||||
}
|
||||
|
||||
private async dispatch<TResult>(
|
||||
command: SidecarCommand,
|
||||
onDelta?: (event: TurnDeltaEvent) => void,
|
||||
): Promise<TResult> {
|
||||
const process = await this.ensureProcess();
|
||||
|
||||
return new Promise<TResult>((resolve, reject) => {
|
||||
if (command.type === 'run-turn') {
|
||||
this.pending.set(command.requestId, {
|
||||
kind: 'run-turn',
|
||||
resolve: resolve as (messages: ChatMessageRecord[]) => void,
|
||||
reject,
|
||||
onDelta: onDelta ?? (() => undefined),
|
||||
});
|
||||
} else if (command.type === 'validate-pattern') {
|
||||
this.pending.set(command.requestId, {
|
||||
kind: 'validate-pattern',
|
||||
resolve: resolve as (issues: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
} else {
|
||||
this.pending.set(command.requestId, {
|
||||
kind: 'capabilities',
|
||||
resolve: resolve as (capabilities: SidecarCapabilities) => void,
|
||||
reject,
|
||||
});
|
||||
}
|
||||
|
||||
process.stdin.write(`${JSON.stringify(command)}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
private flushStdoutBuffer(): void {
|
||||
let newlineIndex = this.stdoutBuffer.indexOf('\n');
|
||||
|
||||
while (newlineIndex >= 0) {
|
||||
const rawLine = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
||||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
||||
|
||||
if (rawLine) {
|
||||
this.handleEvent(JSON.parse(rawLine) as SidecarEvent);
|
||||
}
|
||||
|
||||
newlineIndex = this.stdoutBuffer.indexOf('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: SidecarEvent): void {
|
||||
const pending = this.pending.get(event.requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'capabilities':
|
||||
if (pending.kind === 'capabilities') {
|
||||
pending.resolve(event.capabilities);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'pattern-validation':
|
||||
if (pending.kind === 'validate-pattern') {
|
||||
pending.resolve(event.issues);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'turn-delta':
|
||||
if (pending.kind === 'run-turn') {
|
||||
pending.onDelta(event);
|
||||
}
|
||||
return;
|
||||
case 'turn-complete':
|
||||
if (pending.kind === 'run-turn') {
|
||||
pending.resolve(event.messages);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'command-error':
|
||||
pending.reject(new Error(event.message));
|
||||
this.pending.delete(event.requestId);
|
||||
return;
|
||||
case 'command-complete':
|
||||
if (pending.kind !== 'run-turn') {
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function createMainWindow(): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 960,
|
||||
minWidth: 1120,
|
||||
minHeight: 720,
|
||||
title: 'kopaya',
|
||||
backgroundColor: '#0f172a',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
|
||||
|
||||
if (rendererUrl) {
|
||||
void window.loadURL(rendererUrl);
|
||||
} else {
|
||||
void window.loadFile(join(__dirname, '../../dist/renderer/index.html'));
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
return window;
|
||||
}
|
||||
Reference in New Issue
Block a user