socket message and drone workspace cleanup
This commit is contained in:
parent
0bb789ea6b
commit
4642609d06
@ -1,6 +1,6 @@
|
||||
const API_BASE = '';
|
||||
const API_BASE = "";
|
||||
|
||||
const TOKEN_KEY = 'dtp_auth_token';
|
||||
const TOKEN_KEY = "dtp_auth_token";
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean;
|
||||
@ -21,21 +21,21 @@ function getToken(): string | null {
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Record<string, unknown>
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
credentials: "include",
|
||||
};
|
||||
|
||||
if (body) {
|
||||
@ -46,7 +46,7 @@ async function request<T>(
|
||||
const text = await response.text();
|
||||
|
||||
if (!text) {
|
||||
throw new Error('Empty response');
|
||||
throw new Error("Empty response");
|
||||
}
|
||||
|
||||
try {
|
||||
@ -56,7 +56,7 @@ async function request<T>(
|
||||
}
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.message || 'Request failed');
|
||||
throw new Error(json.message || "Request failed");
|
||||
}
|
||||
|
||||
if (json.token !== undefined && json.user !== undefined) {
|
||||
@ -69,12 +69,12 @@ async function request<T>(
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body: Record<string, unknown>) =>
|
||||
request<T>('POST', path, body),
|
||||
get: <T>(path: string) => request<T>("GET", path),
|
||||
post: <T>(path: string, body?: Record<string, unknown>) =>
|
||||
request<T>("POST", path, body),
|
||||
put: <T>(path: string, body: Record<string, unknown>) =>
|
||||
request<T>('PUT', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
request<T>("PUT", path, body),
|
||||
delete: <T>(path: string) => request<T>("DELETE", path),
|
||||
};
|
||||
|
||||
export interface User {
|
||||
@ -93,19 +93,26 @@ export interface Project {
|
||||
_id: string;
|
||||
createdAt: string;
|
||||
user: string;
|
||||
status: 'active' | 'inactive' | 'archived';
|
||||
status: "active" | "inactive" | "archived";
|
||||
name: string;
|
||||
slug: string;
|
||||
gitUrl?: string;
|
||||
}
|
||||
|
||||
export const projectApi = {
|
||||
getAll: () => api.get<Project[]>('/api/v1/projects'),
|
||||
getAll: () => api.get<Project[]>("/api/v1/projects"),
|
||||
get: (id: string) => api.get<Project>(`/api/v1/projects/${id}`),
|
||||
create: (data: { name: string; slug: string; gitUrl?: string }) =>
|
||||
api.post<Project>('/api/v1/projects', data),
|
||||
update: (id: string, data: Partial<{ name: string; slug: string; gitUrl: string; status: string }>) =>
|
||||
api.put<Project>(`/api/v1/projects/${id}`, data),
|
||||
api.post<Project>("/api/v1/projects", data),
|
||||
update: (
|
||||
id: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
slug: string;
|
||||
gitUrl: string;
|
||||
status: string;
|
||||
}>,
|
||||
) => api.put<Project>(`/api/v1/projects/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/v1/projects/${id}`),
|
||||
};
|
||||
|
||||
@ -113,16 +120,20 @@ export interface DroneRegistration {
|
||||
_id: string;
|
||||
hostname: string;
|
||||
workspaceDir: string;
|
||||
status: 'starting' | 'available' | 'busy' | 'offline';
|
||||
status: "starting" | "available" | "busy" | "offline";
|
||||
user: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const droneApi = {
|
||||
getAll: () => api.get<DroneRegistration[]>('/api/v1/drone/registration'),
|
||||
getForManager: () => api.get<DroneRegistration[]>('/api/v1/drone/registration/manager'),
|
||||
terminate: (registrationId: string) => api.post<ApiResponse>(`/api/v1/drone/registration/${registrationId}/terminate`),
|
||||
getAll: () => api.get<DroneRegistration[]>("/api/v1/drone/registration"),
|
||||
getForManager: () =>
|
||||
api.get<DroneRegistration[]>("/api/v1/drone/registration/manager"),
|
||||
terminate: (registrationId: string) =>
|
||||
api.post<ApiResponse>(
|
||||
`/api/v1/drone/registration/${registrationId}/terminate`,
|
||||
),
|
||||
};
|
||||
|
||||
export interface AiModelCapabilities {
|
||||
@ -153,7 +164,7 @@ export interface AiModel {
|
||||
export interface AiProvider {
|
||||
_id: string;
|
||||
name: string;
|
||||
apiType: 'ollama' | 'openai';
|
||||
apiType: "ollama" | "openai";
|
||||
baseUrl: string;
|
||||
enabled: boolean;
|
||||
models: AiModel[];
|
||||
@ -161,16 +172,16 @@ export interface AiProvider {
|
||||
}
|
||||
|
||||
export const providerApi = {
|
||||
getAll: () => api.get<AiProvider[]>('/api/v1/providers'),
|
||||
getAll: () => api.get<AiProvider[]>("/api/v1/providers"),
|
||||
get: (id: string) => api.get<AiProvider>(`/api/v1/providers/${id}`),
|
||||
};
|
||||
|
||||
export enum ChatSessionMode {
|
||||
Plan = 'plan',
|
||||
Build = 'build',
|
||||
Test = 'test',
|
||||
Ship = 'ship',
|
||||
Develop = 'dev',
|
||||
Plan = "plan",
|
||||
Build = "build",
|
||||
Test = "test",
|
||||
Ship = "ship",
|
||||
Develop = "dev",
|
||||
}
|
||||
|
||||
export interface ChatSessionStats {
|
||||
@ -217,7 +228,7 @@ export interface ChatTurn {
|
||||
provider: string | AiProvider;
|
||||
llm: string;
|
||||
mode: ChatSessionMode;
|
||||
status: 'processing' | 'finished' | 'error';
|
||||
status: "processing" | "finished" | "error";
|
||||
prompts: ChatTurnPrompts;
|
||||
thinking?: string;
|
||||
response?: string;
|
||||
@ -234,7 +245,7 @@ export interface ChatTurn {
|
||||
export const chatSessionApi = {
|
||||
getAll: (projectId?: string) =>
|
||||
api.get<ChatSession[]>(
|
||||
`/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ''}`
|
||||
`/api/v1/chat-sessions${projectId ? `?projectId=${projectId}` : ""}`,
|
||||
),
|
||||
get: (id: string) => api.get<ChatSession>(`/api/v1/chat-sessions/${id}`),
|
||||
create: (data: {
|
||||
@ -243,9 +254,10 @@ export const chatSessionApi = {
|
||||
selectedModel: string;
|
||||
mode?: ChatSessionMode;
|
||||
name?: string;
|
||||
}) => api.post<ChatSession>('/api/v1/chat-sessions', data),
|
||||
}) => api.post<ChatSession>("/api/v1/chat-sessions", data),
|
||||
update: (id: string, data: Partial<ChatSession>) =>
|
||||
api.put<ChatSession>(`/api/v1/chat-sessions/${id}`, data),
|
||||
delete: (id: string) => api.delete<void>(`/api/v1/chat-sessions/${id}`),
|
||||
getTurns: (id: string) => api.get<ChatTurn[]>(`/api/v1/chat-sessions/${id}/turns`),
|
||||
getTurns: (id: string) =>
|
||||
api.get<ChatTurn[]>(`/api/v1/chat-sessions/${id}/turns`),
|
||||
};
|
||||
@ -1,13 +1,22 @@
|
||||
import { createContext } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { createContext } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
|
||||
const SOCKET_URL = '';
|
||||
const SOCKET_URL = "";
|
||||
|
||||
export interface ServerToClientEvents {
|
||||
thinking: (content: string) => void;
|
||||
response: (content: string) => void;
|
||||
toolCall: (callId: string, name: string, params: string, response: string) => void;
|
||||
workOrderComplete: (turnId: string, success: boolean, message?: string) => void;
|
||||
toolCall: (
|
||||
callId: string,
|
||||
name: string,
|
||||
params: string,
|
||||
response: string,
|
||||
) => void;
|
||||
workOrderComplete: (
|
||||
turnId: string,
|
||||
success: boolean,
|
||||
message?: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface ClientToServerEvents {
|
||||
@ -16,92 +25,132 @@ export interface ClientToServerEvents {
|
||||
registration: any,
|
||||
project: any,
|
||||
chatSession: any,
|
||||
cb: (success: boolean, chatSessionId: string) => void
|
||||
cb: (success: boolean, chatSessionId: string) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface SocketEvents {
|
||||
'agent:thinking': (data: { agentId: string; thinking: string }) => void;
|
||||
'agent:response': (data: { agentId: string; chunk: string }) => void;
|
||||
'agent:tool-call': (data: { agentId: string; tool: string; args: unknown }) => void;
|
||||
'agent:tool-result': (data: { agentId: string; tool: string; result: unknown }) => void;
|
||||
'agent:complete': (data: { agentId: string }) => void;
|
||||
'log:entry': (data: { level: string; message: string; timestamp: number }) => void;
|
||||
'chat:message': (data: { agentId: string; message: string; role: 'user' | 'assistant' | 'system' }) => void;
|
||||
thinking: (content: string) => void;
|
||||
response: (content: string) => void;
|
||||
toolCall: (
|
||||
callId: string,
|
||||
name: string,
|
||||
params: string,
|
||||
response: string,
|
||||
) => void;
|
||||
workOrderComplete: (
|
||||
turnId: string,
|
||||
success: boolean,
|
||||
message?: string,
|
||||
) => void;
|
||||
"agent:thinking": (data: { agentId: string; thinking: string }) => void;
|
||||
"agent:response": (data: { agentId: string; chunk: string }) => void;
|
||||
"agent:tool-call": (data: {
|
||||
agentId: string;
|
||||
tool: string;
|
||||
args: unknown;
|
||||
}) => void;
|
||||
"agent:tool-result": (data: {
|
||||
agentId: string;
|
||||
tool: string;
|
||||
result: unknown;
|
||||
}) => void;
|
||||
"agent:complete": (data: { agentId: string }) => void;
|
||||
"log:entry": (data: {
|
||||
level: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}) => void;
|
||||
"chat:message": (data: {
|
||||
agentId: string;
|
||||
message: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
}) => void;
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
disconnect: (reason: string) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
|
||||
class SocketClient {
|
||||
private socket: Socket | null = null;
|
||||
private eventListeners: Map<string, Set<(...args: unknown[]) => void>> = new Map();
|
||||
private _socket: Socket | null = null;
|
||||
private eventListeners: Map<string, Set<(...args: unknown[]) => void>> =
|
||||
new Map();
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private jwt: string | null = null;
|
||||
|
||||
get connected(): boolean {
|
||||
return this.socket?.connected ?? false;
|
||||
return this._socket?.connected ?? false;
|
||||
}
|
||||
|
||||
get socket(): Socket | null {
|
||||
return this.socket;
|
||||
return this._socket;
|
||||
}
|
||||
|
||||
connect(token: string): void {
|
||||
if (this.socket?.connected) {
|
||||
if (this._socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.jwt = token;
|
||||
|
||||
this.socket = io(SOCKET_URL, {
|
||||
this._socket = io(SOCKET_URL, {
|
||||
auth: {
|
||||
token: this.jwt,
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.maxReconnectAttempts,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
});
|
||||
|
||||
if (!this.socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward server events to our event listeners
|
||||
this.socket.on('thinking', (content: string) => {
|
||||
this.emit('thinking', content);
|
||||
this.socket.on("thinking", (content: string) => {
|
||||
this.emit("thinking", content);
|
||||
});
|
||||
|
||||
this.socket.on('response', (content: string) => {
|
||||
this.emit('response', content);
|
||||
this.socket.on("response", (content: string) => {
|
||||
this.emit("response", content);
|
||||
});
|
||||
|
||||
this.socket.on('toolCall', (callId: string, name: string, params: string, response: string) => {
|
||||
this.emit('toolCall', callId, name, params, response);
|
||||
});
|
||||
this.socket.on(
|
||||
"toolCall",
|
||||
(callId: string, name: string, params: string, response: string) => {
|
||||
this.emit("toolCall", callId, name, params, response);
|
||||
},
|
||||
);
|
||||
|
||||
this.socket.on('workOrderComplete', (turnId: string, success: boolean, message?: string) => {
|
||||
this.emit('workOrderComplete', turnId, success, message);
|
||||
});
|
||||
this.socket.on(
|
||||
"workOrderComplete",
|
||||
(turnId: string, success: boolean, message?: string) => {
|
||||
this.emit("workOrderComplete", turnId, success, message);
|
||||
},
|
||||
);
|
||||
|
||||
this.socket.on('connect', () => {
|
||||
this._socket.on("connect", () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connect');
|
||||
this.emit("connect");
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
this.emit('disconnect', reason);
|
||||
this._socket.on("disconnect", (reason) => {
|
||||
this.emit("disconnect", reason);
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
this._socket.on("connect_error", (error) => {
|
||||
this.reconnectAttempts++;
|
||||
this.emit('error', error);
|
||||
this.emit("error", error);
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
if (this._socket) {
|
||||
this._socket.disconnect();
|
||||
this._socket = null;
|
||||
this.jwt = null;
|
||||
}
|
||||
}
|
||||
@ -110,7 +159,9 @@ class SocketClient {
|
||||
if (!this.eventListeners.has(event)) {
|
||||
this.eventListeners.set(event, new Set());
|
||||
}
|
||||
this.eventListeners.get(event)!.add(callback as (...args: unknown[]) => void);
|
||||
this.eventListeners
|
||||
.get(event)!
|
||||
.add(callback as (...args: unknown[]) => void);
|
||||
}
|
||||
|
||||
off<K extends keyof SocketEvents>(event: K, callback: SocketEvents[K]): void {
|
||||
@ -120,35 +171,48 @@ class SocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
emit<K extends keyof SocketEvents>(event: K, ...args: Parameters<SocketEvents[K]>): void {
|
||||
emit<K extends keyof SocketEvents>(
|
||||
event: K,
|
||||
...args: Parameters<SocketEvents[K]>
|
||||
): void {
|
||||
const listeners = this.eventListeners.get(event);
|
||||
if (listeners) {
|
||||
listeners.forEach((callback) => callback(...args));
|
||||
}
|
||||
}
|
||||
|
||||
send<K extends keyof SocketEvents>(event: K, ...args: Parameters<SocketEvents[K]>): void {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit(event, ...args);
|
||||
send<K extends keyof SocketEvents>(
|
||||
event: K,
|
||||
...args: Parameters<SocketEvents[K]>
|
||||
): void {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit(event, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
emitServer<K extends keyof ClientToServerEvents>(event: K, ...args: Parameters<ClientToServerEvents[K]>): void {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit(event, ...args);
|
||||
emitServer<K extends keyof ClientToServerEvents>(
|
||||
event: K,
|
||||
...args: Parameters<ClientToServerEvents[K]>
|
||||
): void {
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit(event, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
requestSessionLock(
|
||||
registration: any,
|
||||
project: any,
|
||||
chatSession: any
|
||||
chatSession: any,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit('requestSessionLock', registration, project, chatSession, (success: boolean) => {
|
||||
resolve(success);
|
||||
});
|
||||
if (this._socket?.connected) {
|
||||
this._socket.emit(
|
||||
"requestSessionLock",
|
||||
registration,
|
||||
project,
|
||||
chatSession,
|
||||
resolve,
|
||||
);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ function SystemReady() {
|
||||
href="/sign-in"
|
||||
className="inline-block px-4 py-2 border border-border-highlight text-text-primary hover:bg-bg-tertiary hover:text-text-primary rounded transition-colors"
|
||||
>
|
||||
[ Sign In ]
|
||||
Sign In
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,35 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import type { User, Project } from '../lib/api';
|
||||
import { projectApi, droneApi, chatSessionApi, type DroneRegistration, type ChatSession, type AiProvider, providerApi } from '../lib/api';
|
||||
import { socketClient } from '../lib/socket';
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import type { User, Project } from "../lib/api";
|
||||
import {
|
||||
projectApi,
|
||||
droneApi,
|
||||
chatSessionApi,
|
||||
type DroneRegistration,
|
||||
type ChatSession,
|
||||
type AiProvider,
|
||||
providerApi,
|
||||
} from "../lib/api";
|
||||
import { socketClient } from "../lib/socket";
|
||||
|
||||
interface ProjectManagerProps {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSuccess: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [gitUrl, setGitUrl] = useState('');
|
||||
function NewProjectForm({
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}: {
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [gitUrl, setGitUrl] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !slug) {
|
||||
setError('Name and slug are required');
|
||||
setError("Name and slug are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setError("");
|
||||
|
||||
try {
|
||||
await projectApi.create({ name, slug, gitUrl: gitUrl || undefined });
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create project');
|
||||
setError(err instanceof Error ? err.message : "Failed to create project");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@ -40,7 +54,9 @@ function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSucce
|
||||
<h2 className="text-xl font-semibold mb-6">Create New Project</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">Project Name *</label>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
Project Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
@ -50,7 +66,9 @@ function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSucce
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">Project Slug *</label>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
Project Slug *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
@ -58,10 +76,14 @@ function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSucce
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
||||
placeholder="my-project"
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">Unique identifier for the project directory</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Unique identifier for the project directory
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">Git Repository URL</label>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
Git Repository URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={gitUrl}
|
||||
@ -77,7 +99,7 @@ function NewProjectForm({ onCancel, onSuccess }: { onCancel: () => void; onSucce
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Project'}
|
||||
{submitting ? "Creating..." : "Create Project"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -101,7 +123,11 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Are you sure you want to delete this project? This cannot be undone.')) {
|
||||
if (
|
||||
!confirm(
|
||||
"Are you sure you want to delete this project? This cannot be undone.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
@ -109,7 +135,7 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
|
||||
await projectApi.delete(project._id);
|
||||
onDelete();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete project', err);
|
||||
console.error("Failed to delete project", err);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
@ -123,7 +149,9 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-1">Name</div>
|
||||
<div className="text-text-primary font-medium">{project.name}</div>
|
||||
<div className="text-text-primary font-medium">
|
||||
{project.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-1">Slug</div>
|
||||
@ -134,7 +162,9 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-1">Git URL</div>
|
||||
<div className="text-text-primary font-mono text-sm break-all">
|
||||
{project.gitUrl || <span className="text-text-muted">Not configured</span>}
|
||||
{project.gitUrl || (
|
||||
<span className="text-text-muted">Not configured</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -142,10 +172,15 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
|
||||
<div className="p-4 bg-bg-secondary border border-border-default rounded">
|
||||
<div className="text-sm text-text-muted mb-1">Status</div>
|
||||
<div className="text-text-primary capitalize">
|
||||
<span className={`inline-block w-2 h-2 rounded-full mr-2 ${
|
||||
project.status === 'active' ? 'bg-green-500' :
|
||||
project.status === 'inactive' ? 'bg-yellow-500' : 'bg-gray-500'
|
||||
}`} />
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full mr-2 ${
|
||||
project.status === "active"
|
||||
? "bg-green-500"
|
||||
: project.status === "inactive"
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
{project.status}
|
||||
</div>
|
||||
</div>
|
||||
@ -163,7 +198,7 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 border border-red-600 text-red-500 rounded hover:bg-red-900/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Project'}
|
||||
{deleting ? "Deleting..." : "Delete Project"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -174,17 +209,25 @@ function ProjectInspector({ project, onDelete }: ProjectInspectorProps) {
|
||||
|
||||
interface RightSidebarProps {
|
||||
project: Project;
|
||||
selectedDrone: DroneRegistration | null;
|
||||
onSelectDrone: (drone: DroneRegistration) => void;
|
||||
onOpenChatSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
function RightSidebar({
|
||||
project,
|
||||
selectedDrone,
|
||||
onSelectDrone,
|
||||
onOpenChatSession,
|
||||
}: RightSidebarProps) {
|
||||
const [drones, setDrones] = useState<DroneRegistration[]>([]);
|
||||
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
|
||||
const [providers, setProviders] = useState<AiProvider[]>([]);
|
||||
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
|
||||
const [showNewChatModal, setShowNewChatModal] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingSessions, setDeletingSessions] = useState<Set<string>>(new Set());
|
||||
const [deletingSessions, setDeletingSessions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
@ -205,16 +248,12 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
const allProviders = await providerApi.getAll();
|
||||
setProviders(allProviders);
|
||||
} catch (err) {
|
||||
console.error('Failed to load sidebar data', err);
|
||||
console.error("Failed to load sidebar data", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectDrone = (drone: DroneRegistration) => {
|
||||
setSelectedDrone(drone);
|
||||
};
|
||||
|
||||
const handleCreateChatSession = async (data: {
|
||||
providerId: string;
|
||||
selectedModel: string;
|
||||
@ -233,29 +272,33 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
|
||||
// Lock the drone to this session BEFORE navigating
|
||||
if (selectedDrone) {
|
||||
const success = await socketClient.requestSessionLock(selectedDrone, project, session);
|
||||
const success = await socketClient.requestSessionLock(
|
||||
selectedDrone,
|
||||
project,
|
||||
session,
|
||||
);
|
||||
if (!success) {
|
||||
console.error('Failed to lock drone session');
|
||||
console.error("Failed to lock drone session");
|
||||
}
|
||||
}
|
||||
|
||||
onOpenChatSession(session._id);
|
||||
await loadData(); // Refresh list
|
||||
} catch (err) {
|
||||
console.error('Failed to create chat session', err);
|
||||
console.error("Failed to create chat session", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChatSession = async (sessionId: string) => {
|
||||
if (!confirm('Delete this chat session?')) return;
|
||||
setDeletingSessions(prev => new Set(prev).add(sessionId));
|
||||
if (!confirm("Delete this chat session?")) return;
|
||||
setDeletingSessions((prev) => new Set(prev).add(sessionId));
|
||||
try {
|
||||
await chatSessionApi.delete(sessionId);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete chat session', err);
|
||||
console.error("Failed to delete chat session", err);
|
||||
} finally {
|
||||
setDeletingSessions(prev => {
|
||||
setDeletingSessions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(sessionId);
|
||||
return next;
|
||||
@ -267,7 +310,10 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
<>
|
||||
<aside className="w-80 border-l border-border-subtle bg-bg-secondary flex flex-col overflow-hidden">
|
||||
{/* Available Drones Section - 40% of available space (excluding button row) */}
|
||||
<div className="flex flex-col min-h-0 flex-[2]" style={{ minHeight: 0 }}>
|
||||
<div
|
||||
className="flex flex-col min-h-0 flex-[2]"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<div className="p-3 border-b border-border-subtle flex-shrink-0">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Available Drones ({drones.length})
|
||||
@ -278,7 +324,8 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
<p className="text-sm text-text-muted p-2">Loading...</p>
|
||||
) : drones.length === 0 ? (
|
||||
<div className="text-text-muted text-sm p-2">
|
||||
No available drones. Start a gadget-drone instance to begin working.
|
||||
No available drones. Start a gadget-drone instance to begin
|
||||
working.
|
||||
</div>
|
||||
) : (
|
||||
drones.map((drone) => (
|
||||
@ -286,35 +333,37 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
key={drone._id}
|
||||
className={`p-3 border rounded transition-colors ${
|
||||
selectedDrone?._id === drone._id
|
||||
? 'border-brand bg-bg-tertiary'
|
||||
: 'border-border-default bg-bg-tertiary hover:bg-bg-elevated'
|
||||
? "border-brand bg-bg-tertiary"
|
||||
: "border-border-default bg-bg-tertiary hover:bg-bg-elevated"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
drone.status === 'available'
|
||||
? 'bg-green-500'
|
||||
: drone.status === 'busy'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-gray-500'
|
||||
drone.status === "available"
|
||||
? "bg-green-500"
|
||||
: drone.status === "busy"
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-mono text-sm font-medium">{drone.hostname}</span>
|
||||
<span className="font-mono text-sm font-medium">
|
||||
{drone.hostname}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSelectDrone(drone)}
|
||||
disabled={drone.status === 'busy'}
|
||||
onClick={() => onSelectDrone(drone)}
|
||||
disabled={drone.status === "busy"}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
selectedDrone?._id === drone._id
|
||||
? 'bg-brand text-white'
|
||||
: drone.status === 'busy'
|
||||
? 'bg-bg-tertiary text-text-muted cursor-not-allowed'
|
||||
: 'border border-border-default hover:bg-bg-elevated'
|
||||
? "bg-brand text-white"
|
||||
: drone.status === "busy"
|
||||
? "bg-bg-tertiary text-text-muted cursor-not-allowed"
|
||||
: "border border-border-default hover:bg-bg-elevated"
|
||||
}`}
|
||||
>
|
||||
{selectedDrone?._id === drone._id ? 'Selected' : 'Select'}
|
||||
{selectedDrone?._id === drone._id ? "Selected" : "Select"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted truncate">
|
||||
@ -330,7 +379,10 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Chat Sessions Section - 60% of available space (excluding button row) */}
|
||||
<div className="flex flex-col min-h-0 flex-[3]" style={{ minHeight: 0 }}>
|
||||
<div
|
||||
className="flex flex-col min-h-0 flex-[3]"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<div className="p-3 border-b border-border-subtle flex-shrink-0 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-text-secondary uppercase tracking-wider">
|
||||
Chat Sessions ({chatSessions.length})
|
||||
@ -370,7 +422,17 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
className="absolute top-2 right-2 p-1.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-900/50 text-text-muted hover:text-red-400 transition-all disabled:opacity-50"
|
||||
title="Delete session"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
</svg>
|
||||
@ -387,9 +449,13 @@ function RightSidebar({ project, onOpenChatSession }: RightSidebarProps) {
|
||||
onClick={() => setShowNewChatModal(true)}
|
||||
disabled={!selectedDrone}
|
||||
className="w-full px-4 py-2.5 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={!selectedDrone ? 'Select a drone first' : 'Create new chat session'}
|
||||
title={
|
||||
!selectedDrone
|
||||
? "Select a drone first"
|
||||
: "Create new chat session"
|
||||
}
|
||||
>
|
||||
[New Chat Session]
|
||||
New Chat Session
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@ -411,17 +477,27 @@ interface NewChatSessionModalProps {
|
||||
providers: AiProvider[];
|
||||
selectedDrone: DroneRegistration | null;
|
||||
onCancel: () => void;
|
||||
onCreate: (data: { providerId: string; selectedModel: string; mode: string; name?: string }) => void;
|
||||
onCreate: (data: {
|
||||
providerId: string;
|
||||
selectedModel: string;
|
||||
mode: string;
|
||||
name?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: NewChatSessionModalProps) {
|
||||
const [selectedProviderId, setSelectedProviderId] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [mode, setMode] = useState('build');
|
||||
const [name, setName] = useState('');
|
||||
function NewChatSessionModal({
|
||||
providers,
|
||||
selectedDrone,
|
||||
onCancel,
|
||||
onCreate,
|
||||
}: NewChatSessionModalProps) {
|
||||
const [selectedProviderId, setSelectedProviderId] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [mode, setMode] = useState("build");
|
||||
const [name, setName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const selectedProvider = providers.find(p => p._id === selectedProviderId);
|
||||
const selectedProvider = providers.find((p) => p._id === selectedProviderId);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@ -431,14 +507,14 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
await onCreate({
|
||||
onCreate({
|
||||
providerId: selectedProviderId,
|
||||
selectedModel,
|
||||
mode,
|
||||
name: name || undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to create chat session', err);
|
||||
console.error("Failed to create chat session", err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@ -451,13 +527,19 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
|
||||
{selectedDrone && (
|
||||
<div className="mb-4 p-3 bg-bg-tertiary border border-border-default rounded">
|
||||
<div className="text-xs text-text-muted mb-1">Selected Drone</div>
|
||||
<div className="font-mono text-sm text-text-primary">{selectedDrone.hostname}</div>
|
||||
<div className="text-xs text-text-muted truncate">{selectedDrone.workspaceDir}</div>
|
||||
<div className="font-mono text-sm text-text-primary">
|
||||
{selectedDrone.hostname}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted truncate">
|
||||
{selectedDrone.workspaceDir}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">Session Name (optional)</label>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
Session Name (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
@ -468,12 +550,14 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">AI Provider *</label>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
AI Provider *
|
||||
</label>
|
||||
<select
|
||||
value={selectedProviderId}
|
||||
onChange={(e) => {
|
||||
setSelectedProviderId(e.target.value);
|
||||
setSelectedModel('');
|
||||
setSelectedModel("");
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-bg-tertiary border border-border-default rounded text-text-primary focus:border-brand focus:outline-none"
|
||||
>
|
||||
@ -488,7 +572,9 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
|
||||
|
||||
{selectedProvider && (
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">Model *</label>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
Model *
|
||||
</label>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
@ -498,20 +584,24 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
|
||||
<option value="">Select a model</option>
|
||||
{selectedProvider.models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name} {model.parameterLabel ? `(${model.parameterLabel})` : ''}
|
||||
{model.name}{" "}
|
||||
{model.parameterLabel ? `(${model.parameterLabel})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedProvider.models.length === 0 && (
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
No models discovered. Run `pnpm cli provider probe {selectedProvider._id}` to discover models.
|
||||
No models discovered. Run `pnpm cli provider probe{" "}
|
||||
{selectedProvider._id}` to discover models.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-text-secondary mb-1">Mode</label>
|
||||
<label className="block text-sm text-text-secondary mb-1">
|
||||
Mode
|
||||
</label>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
@ -531,7 +621,7 @@ function NewChatSessionModal({ providers, selectedDrone, onCancel, onCreate }: N
|
||||
disabled={creating || !selectedProviderId || !selectedModel}
|
||||
className="px-4 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors disabled:opacity-50 flex-1"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Session'}
|
||||
{creating ? "Creating..." : "Create Session"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -552,6 +642,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||
const { slug } = useParams();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||
const [selectedDrone, setSelectedDrone] = useState<DroneRegistration | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showNewForm, setShowNewForm] = useState(false);
|
||||
|
||||
@ -573,7 +664,7 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||
const data = await projectApi.getAll();
|
||||
setProjects(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects', err);
|
||||
console.error("Failed to load projects", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -591,12 +682,30 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||
const handleProjectDeleted = () => {
|
||||
setSelectedProject(null);
|
||||
loadProjects();
|
||||
navigate('/projects');
|
||||
navigate("/projects");
|
||||
};
|
||||
|
||||
const handleOpenChatSession = (sessionId: string) => {
|
||||
if (selectedProject) {
|
||||
const handleSelectDrone = (drone: DroneRegistration) => {
|
||||
setSelectedDrone(drone);
|
||||
};
|
||||
|
||||
const handleOpenChatSession = async (sessionId: string) => {
|
||||
if (!selectedProject || !selectedDrone) return;
|
||||
|
||||
try {
|
||||
const session = await chatSessionApi.get(sessionId);
|
||||
const success = await socketClient.requestSessionLock(
|
||||
selectedDrone,
|
||||
selectedProject,
|
||||
session,
|
||||
);
|
||||
if (!success) {
|
||||
console.error("Failed to lock drone session");
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${selectedProject._id}/chat-session/${sessionId}`);
|
||||
} catch (err) {
|
||||
console.error("Failed to open chat session", err);
|
||||
}
|
||||
};
|
||||
|
||||
@ -625,13 +734,15 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||
onClick={() => setShowNewForm(true)}
|
||||
className="w-full px-3 py-2 bg-brand text-white rounded hover:bg-red-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
[New Project]
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{showNewForm ? (
|
||||
<div className="p-2">
|
||||
<p className="text-sm text-text-muted mb-2">Creating new project...</p>
|
||||
<p className="text-sm text-text-muted mb-2">
|
||||
Creating new project...
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewForm(false)}
|
||||
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
@ -654,8 +765,8 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||
onClick={() => handleSelectProject(project)}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm rounded truncate transition-colors ${
|
||||
selectedProject?.slug === project.slug
|
||||
? 'bg-brand text-white'
|
||||
: 'text-text-secondary hover:bg-bg-tertiary hover:text-text-primary'
|
||||
? "bg-brand text-white"
|
||||
: "text-text-secondary hover:bg-bg-tertiary hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
{project.name}
|
||||
@ -688,6 +799,8 @@ export default function ProjectManager({ user }: ProjectManagerProps) {
|
||||
{/* Right Sidebar - Drones & Chat Sessions */}
|
||||
<RightSidebar
|
||||
project={selectedProject}
|
||||
selectedDrone={selectedDrone}
|
||||
onSelectDrone={handleSelectDrone}
|
||||
onOpenChatSession={handleOpenChatSession}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -14,10 +14,10 @@ import {
|
||||
IUser,
|
||||
ChatTurnStatus,
|
||||
GadgetId,
|
||||
ChatTurnDocument,
|
||||
} from "@gadget/api";
|
||||
|
||||
import SocketService from "../services/socket.ts";
|
||||
import { ChatTurn } from "../models/chat-turn.ts";
|
||||
import { ChatSessionService, SocketService } from "../services/index.ts";
|
||||
|
||||
export class CodeSession extends SocketSession {
|
||||
protected type: SocketSessionType = SocketSessionType.Code;
|
||||
@ -107,31 +107,10 @@ export class CodeSession extends SocketSession {
|
||||
|
||||
const droneSession = SocketService.getDroneSession(this.selectedDrone);
|
||||
|
||||
const turn = new ChatTurn({
|
||||
createdAt: new Date(),
|
||||
user: this.user._id,
|
||||
project: this.project._id,
|
||||
session: this.chatSession._id,
|
||||
provider: this.chatSession.provider,
|
||||
llm: this.chatSession.selectedModel,
|
||||
mode: this.chatSession.mode,
|
||||
status: ChatTurnStatus.Processing,
|
||||
prompts: {
|
||||
user: content,
|
||||
system: undefined,
|
||||
},
|
||||
toolCalls: [],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "pending",
|
||||
},
|
||||
});
|
||||
await turn.save();
|
||||
let turn: ChatTurnDocument = await ChatSessionService.createTurn(
|
||||
this.chatSession,
|
||||
content,
|
||||
);
|
||||
this.currentTurnId = turn._id;
|
||||
|
||||
this.log.info("ChatTurn created", {
|
||||
|
||||
@ -2,7 +2,15 @@
|
||||
// Copyright (C) 2026 Robert Colbert <rob.colbert@openplatform.us>
|
||||
// All Rights Reserved
|
||||
|
||||
import { IChatSession, ChatSessionMode, GadgetId } from "@gadget/api";
|
||||
import {
|
||||
IChatSession,
|
||||
ChatSessionMode,
|
||||
GadgetId,
|
||||
IUser,
|
||||
IProject,
|
||||
ChatTurnStatus,
|
||||
ChatTurnDocument,
|
||||
} from "@gadget/api";
|
||||
|
||||
import { DtpService } from "../lib/service.js";
|
||||
import { PopulateOptions } from "mongoose";
|
||||
@ -14,9 +22,34 @@ import AiProvider from "../models/ai-provider.js";
|
||||
class ChatSessionService extends DtpService {
|
||||
private populateSession: PopulateOptions[] = [
|
||||
{ path: "user", select: "-passwordSalt -password" },
|
||||
{ path: "project" },
|
||||
{
|
||||
path: "project",
|
||||
populate: [
|
||||
{
|
||||
path: "user",
|
||||
select: "-passwordSalt -password",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: "provider" },
|
||||
];
|
||||
private populateChatTurn: PopulateOptions[] = [
|
||||
{
|
||||
path: "user",
|
||||
select: "-passwordSalt -password",
|
||||
},
|
||||
{
|
||||
path: "project",
|
||||
},
|
||||
{
|
||||
path: "session",
|
||||
populate: this.populateSession,
|
||||
},
|
||||
{
|
||||
path: "provider",
|
||||
select: "-models",
|
||||
},
|
||||
];
|
||||
|
||||
get name(): string {
|
||||
return "ChatSessionService";
|
||||
@ -193,6 +226,45 @@ class ChatSessionService extends DtpService {
|
||||
});
|
||||
}
|
||||
|
||||
async createTurn(
|
||||
session: IChatSession,
|
||||
prompt: string,
|
||||
): Promise<ChatTurnDocument> {
|
||||
const NOW = new Date();
|
||||
const user: IUser = session.user as IUser;
|
||||
const project: IProject = session.project as IProject;
|
||||
|
||||
let turn = new ChatTurn({
|
||||
createdAt: NOW,
|
||||
user: user._id,
|
||||
project: project._id,
|
||||
session: session._id,
|
||||
provider: session.provider,
|
||||
llm: session.selectedModel,
|
||||
mode: session.mode,
|
||||
status: ChatTurnStatus.Processing,
|
||||
prompts: {
|
||||
user: prompt,
|
||||
system: undefined,
|
||||
},
|
||||
toolCalls: [],
|
||||
subagents: [],
|
||||
stats: {
|
||||
toolCallCount: 0,
|
||||
inputTokens: 0,
|
||||
thinkingTokenCount: 0,
|
||||
responseTokens: 0,
|
||||
durationMs: 0,
|
||||
durationLabel: "pending",
|
||||
},
|
||||
});
|
||||
|
||||
await turn.save();
|
||||
turn = await ChatTurn.populate(turn, this.populateChatTurn);
|
||||
|
||||
return turn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all turns for a chat session.
|
||||
*/
|
||||
|
||||
@ -40,6 +40,8 @@ class GadgetDrone extends GadgetProcess {
|
||||
private user: IUser | undefined;
|
||||
|
||||
private workspaceMode: WorkspaceMode = WorkspaceMode.Syncing;
|
||||
private chatSession: IChatSession | undefined;
|
||||
|
||||
private socket: ClientSocket | undefined;
|
||||
private isShuttingDown: boolean = false;
|
||||
|
||||
@ -218,11 +220,10 @@ class GadgetDrone extends GadgetProcess {
|
||||
chatSession: IChatSession,
|
||||
cb: RequestSessionLockCallback,
|
||||
) {
|
||||
this.log.info("requestSessionLock received", {
|
||||
registration,
|
||||
project,
|
||||
chatSession,
|
||||
});
|
||||
/*
|
||||
* Validate gadget-drone registration to ensure correct sync with IDE
|
||||
*/
|
||||
|
||||
if (!this.registration) {
|
||||
this.log.warn(
|
||||
"received session lock request without a valid platform registration",
|
||||
@ -240,7 +241,37 @@ class GadgetDrone extends GadgetProcess {
|
||||
return cb(false, "invalid registration");
|
||||
}
|
||||
|
||||
this.workspaceMode = WorkspaceMode.User;
|
||||
/*
|
||||
* Check if the chat session lock is already held.
|
||||
*/
|
||||
|
||||
if (this.chatSession) {
|
||||
if (chatSession._id !== this.chatSession._id) {
|
||||
this.log.warn("rejecting session lock request", {
|
||||
chatSession: {
|
||||
_id: this.chatSession._id,
|
||||
name: this.chatSession.name,
|
||||
},
|
||||
workspaceMode: this.workspaceMode,
|
||||
});
|
||||
return cb(false, this.chatSession._id);
|
||||
}
|
||||
// fall through to grant this lock request
|
||||
this.log.info("chat session is re-connecting to session lock");
|
||||
}
|
||||
|
||||
/*
|
||||
* Grant the chat session lock.
|
||||
*/
|
||||
|
||||
this.log.info("granting session lock", {
|
||||
registrationId: registration._id,
|
||||
project: { _id: project._id, slug: project.slug },
|
||||
chatSession: { _id: chatSession._id, name: chatSession.name },
|
||||
});
|
||||
this.chatSession = chatSession;
|
||||
this.workspaceMode = WorkspaceMode.Idle;
|
||||
|
||||
cb(true, chatSession._id);
|
||||
}
|
||||
|
||||
@ -251,6 +282,16 @@ class GadgetDrone extends GadgetProcess {
|
||||
mode: WorkspaceMode,
|
||||
cb: RequestWorkspaceModeCallback,
|
||||
) {
|
||||
if (!this.chatSession) {
|
||||
return cb(false, this.workspaceMode);
|
||||
}
|
||||
if (chatSession._id !== this.chatSession._id) {
|
||||
this.log.warn("rejecting workspace mode request", {
|
||||
chatSession: { _id: chatSession._id, name: chatSession.name },
|
||||
});
|
||||
return cb(false, this.workspaceMode);
|
||||
}
|
||||
|
||||
this.log.info("requestWorkspaceMode received", {
|
||||
registration,
|
||||
project,
|
||||
@ -270,16 +311,26 @@ class GadgetDrone extends GadgetProcess {
|
||||
turn: IChatTurn,
|
||||
cb: ProcessWorkOrderCallback,
|
||||
) {
|
||||
if (!this.chatSession) {
|
||||
return cb(false, "this drone is not locked to a chat session");
|
||||
}
|
||||
if (this.chatSession._id !== chatSession._id) {
|
||||
return cb(false, "this drone is not locked to your chat session");
|
||||
}
|
||||
if (this.workspaceMode !== WorkspaceMode.Agent) {
|
||||
return cb(false, "this drone's workspace is not in Agent mode");
|
||||
}
|
||||
|
||||
const order: IAgentWorkOrder = {
|
||||
createdAt: turn.createdAt,
|
||||
context: [],
|
||||
turn,
|
||||
};
|
||||
this.log.info("processWorkOrder received", {
|
||||
registration,
|
||||
project,
|
||||
chatSession,
|
||||
turn,
|
||||
registration: { _id: registration._id },
|
||||
project: { _id: project._id, name: project.name },
|
||||
chatSession: { _id: chatSession._id, name: chatSession.name },
|
||||
turn: { _id: turn._id, mode: turn.mode, userPrompt: turn.prompts.user },
|
||||
});
|
||||
|
||||
if (!this.socket) {
|
||||
@ -290,12 +341,7 @@ class GadgetDrone extends GadgetProcess {
|
||||
|
||||
// Write work order cache BEFORE processing (for crash recovery)
|
||||
try {
|
||||
await WorkspaceService.writeWorkOrderCache(
|
||||
turn._id,
|
||||
chatSession._id,
|
||||
project._id,
|
||||
turn.prompts.user,
|
||||
);
|
||||
await WorkspaceService.writeWorkOrderCache(turn);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.log.error("failed to write work order cache", {
|
||||
@ -304,20 +350,18 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Continue anyway - cache is for recovery, not required
|
||||
}
|
||||
|
||||
cb(true); // the drone accepts the work order
|
||||
cb(true, "work order accepted"); // confirm that drone has the work order
|
||||
|
||||
AgentService.process(order, this.socket)
|
||||
.then(async () => {
|
||||
// Remove cache after successful completion
|
||||
await WorkspaceService.removeWorkOrderCache();
|
||||
})
|
||||
.catch(async (error) => {
|
||||
const err = error as Error;
|
||||
this.log.error("work order processing failed", {
|
||||
error: err.message,
|
||||
});
|
||||
// Leave cache in place for recovery
|
||||
try {
|
||||
await AgentService.process(order, this.socket);
|
||||
await WorkspaceService.removeWorkOrderCache();
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.log.error("work order processing failed", {
|
||||
error: err.message,
|
||||
});
|
||||
// Leave cache in place for recovery
|
||||
}
|
||||
}
|
||||
|
||||
hookProcessSignals(): void {
|
||||
@ -378,10 +422,10 @@ class GadgetDrone extends GadgetProcess {
|
||||
return;
|
||||
}
|
||||
|
||||
const chatSession = cache.turn.session as IChatSession;
|
||||
this.log.warn("incomplete work order found - initiating crash recovery", {
|
||||
turnId: cache.turnId,
|
||||
chatSessionId: cache.chatSessionId,
|
||||
workOrderId: cache.workOrderId,
|
||||
turnId: cache.turn._id,
|
||||
chatSessionId: chatSession._id,
|
||||
});
|
||||
|
||||
if (!this.socket) {
|
||||
@ -392,8 +436,8 @@ class GadgetDrone extends GadgetProcess {
|
||||
// Notify web service that this workspace has pending recovery
|
||||
this.socket.emit("requestCrashRecovery", {
|
||||
workspaceId: WorkspaceService.workspaceId!,
|
||||
turnId: cache.turnId,
|
||||
chatSessionId: cache.chatSessionId,
|
||||
turnId: cache.turn._id,
|
||||
chatSessionId: chatSession._id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { GadgetService } from "../lib/service.ts";
|
||||
import { IChatTurn } from "@gadget/api";
|
||||
|
||||
export interface WorkspaceData {
|
||||
workspaceId: string; // UUID v4, immutable once created
|
||||
@ -46,12 +47,9 @@ export interface WorkspaceData {
|
||||
}
|
||||
|
||||
export interface WorkOrderCache {
|
||||
turnId: string; // ChatTurn._id for persistence updates
|
||||
chatSessionId: string; // For routing events back to IDE
|
||||
projectId: string; // For file operations
|
||||
workOrderId: string; // Unique ID for this work order instance
|
||||
id: string;
|
||||
receivedAt: string; // ISO 8601 timestamp
|
||||
prompt: string; // User's prompt (for retry context)
|
||||
turn: IChatTurn;
|
||||
status: "processing" | "completed" | "error";
|
||||
error?: string; // Error message if status === 'error'
|
||||
}
|
||||
@ -111,9 +109,7 @@ class WorkspaceService extends GadgetService {
|
||||
/**
|
||||
* Loads existing workspace data or creates new workspace.
|
||||
*/
|
||||
private async loadOrCreateWorkspaceData(
|
||||
workspaceDir: string,
|
||||
): Promise<void> {
|
||||
private async loadOrCreateWorkspaceData(workspaceDir: string): Promise<void> {
|
||||
try {
|
||||
if (await this.fileExists(this.workspaceFile)) {
|
||||
// Load existing workspace
|
||||
@ -143,9 +139,7 @@ class WorkspaceService extends GadgetService {
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.log.error("failed to load workspace data", { error: err.message });
|
||||
throw new Error(
|
||||
`Failed to initialize workspace: ${err.message}`,
|
||||
);
|
||||
throw new Error(`Failed to initialize workspace: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,9 +160,7 @@ class WorkspaceService extends GadgetService {
|
||||
/**
|
||||
* Updates the chat session in workspace data.
|
||||
*/
|
||||
updateChatSession(
|
||||
chatSession: { _id: string; name: string } | null,
|
||||
): void {
|
||||
updateChatSession(chatSession: { _id: string; name: string } | null): void {
|
||||
if (!this._workspaceData) return;
|
||||
|
||||
this._workspaceData.chatSession = chatSession
|
||||
@ -198,11 +190,7 @@ class WorkspaceService extends GadgetService {
|
||||
/**
|
||||
* Adds a project to the workspace projects list.
|
||||
*/
|
||||
addProject(project: {
|
||||
_id: string;
|
||||
slug: string;
|
||||
gitUrl: string;
|
||||
}): void {
|
||||
addProject(project: { _id: string; slug: string; gitUrl: string }): void {
|
||||
if (!this._workspaceData) return;
|
||||
|
||||
const existing = this._workspaceData.projects.find(
|
||||
@ -220,10 +208,12 @@ class WorkspaceService extends GadgetService {
|
||||
/**
|
||||
* Updates the registration in workspace data.
|
||||
*/
|
||||
updateRegistration(registration: {
|
||||
_id: string;
|
||||
status: string;
|
||||
} | null): void {
|
||||
updateRegistration(
|
||||
registration: {
|
||||
_id: string;
|
||||
status: string;
|
||||
} | null,
|
||||
): void {
|
||||
if (!this._workspaceData) return;
|
||||
|
||||
this._workspaceData.registration = registration
|
||||
@ -237,23 +227,15 @@ class WorkspaceService extends GadgetService {
|
||||
/**
|
||||
* Writes a work order cache file before processing.
|
||||
*/
|
||||
async writeWorkOrderCache(
|
||||
turnId: string,
|
||||
chatSessionId: string,
|
||||
projectId: string,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
async writeWorkOrderCache(turn: IChatTurn): Promise<string> {
|
||||
const crypto = await import("node:crypto");
|
||||
const workOrderId = crypto.randomUUID();
|
||||
const cacheFile = path.join(this.cacheDir, "work-order.json");
|
||||
|
||||
const cache: WorkOrderCache = {
|
||||
turnId,
|
||||
chatSessionId,
|
||||
projectId,
|
||||
workOrderId,
|
||||
id: workOrderId,
|
||||
receivedAt: new Date().toISOString(),
|
||||
prompt,
|
||||
turn,
|
||||
status: "processing",
|
||||
};
|
||||
|
||||
@ -262,10 +244,9 @@ class WorkspaceService extends GadgetService {
|
||||
JSON.stringify(cache, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
this.log.info("work order cache written", {
|
||||
workOrderId,
|
||||
turnId,
|
||||
id: workOrderId,
|
||||
turnId: turn._id,
|
||||
});
|
||||
|
||||
return workOrderId;
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
"folders": [
|
||||
{
|
||||
"path": ".",
|
||||
},
|
||||
{
|
||||
"path": "../../workspaces",
|
||||
},
|
||||
],
|
||||
"settings": {},
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user