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