mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-05 17:27:42 +02:00
629 lines
19 KiB
TypeScript
629 lines
19 KiB
TypeScript
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
|
import { launchAndSendFirstMessage } from './agentsSlice';
|
|
import { API_BASE } from '@/shared/config';
|
|
|
|
const DASHBOARDS_API = `${API_BASE}/dashboards`;
|
|
|
|
export const DEFAULT_CARD_W = 480;
|
|
export const DEFAULT_CARD_H = 280;
|
|
export const DEFAULT_VIEW_CARD_W = 1280;
|
|
export const DEFAULT_VIEW_CARD_H = 800;
|
|
export const DEFAULT_BROWSER_CARD_W = 1280;
|
|
export const DEFAULT_BROWSER_CARD_H = 800;
|
|
export const EXPANDED_CARD_MIN_H = 620;
|
|
const GRID_GAP = 24;
|
|
const GRID_ORIGIN = { x: 40, y: 100 };
|
|
const GRID_COLS_FALLBACK = 4;
|
|
|
|
export interface CardPosition {
|
|
session_id: string;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface ViewCardPosition {
|
|
output_id: string;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface BrowserTab {
|
|
id: string;
|
|
url: string;
|
|
title: string;
|
|
favicon?: string;
|
|
}
|
|
|
|
export interface BrowserCardPosition {
|
|
browser_id: string;
|
|
url: string;
|
|
tabs: BrowserTab[];
|
|
activeTabId: string;
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface DashboardLayoutState {
|
|
cards: Record<string, CardPosition>;
|
|
viewCards: Record<string, ViewCardPosition>;
|
|
browserCards: Record<string, BrowserCardPosition>;
|
|
glowingBrowserCards: Record<string, string>;
|
|
persistedExpandedSessionIds: string[];
|
|
loading: boolean;
|
|
initialized: boolean;
|
|
}
|
|
|
|
const initialState: DashboardLayoutState = {
|
|
cards: {},
|
|
viewCards: {},
|
|
browserCards: {},
|
|
glowingBrowserCards: {},
|
|
persistedExpandedSessionIds: [],
|
|
loading: false,
|
|
initialized: false,
|
|
};
|
|
|
|
interface LayoutPayload {
|
|
cards: Record<string, CardPosition>;
|
|
viewCards: Record<string, ViewCardPosition>;
|
|
browserCards: Record<string, BrowserCardPosition>;
|
|
expandedSessionIds: string[];
|
|
}
|
|
|
|
function generateTabId(): string {
|
|
return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
}
|
|
|
|
export const fetchLayout = createAsyncThunk(
|
|
'dashboardLayout/fetch',
|
|
async (dashboardId: string) => {
|
|
const res = await fetch(`${DASHBOARDS_API}/${dashboardId}`);
|
|
const data = await res.json();
|
|
const layout = data.layout ?? {};
|
|
const browserCards = (layout.browser_cards ?? {}) as Record<string, any>;
|
|
|
|
for (const card of Object.values(browserCards)) {
|
|
if (!card.tabs || card.tabs.length === 0) {
|
|
const tabId = generateTabId();
|
|
card.tabs = [{ id: tabId, url: card.url || 'https://www.google.com', title: '' }];
|
|
card.activeTabId = tabId;
|
|
}
|
|
if (!card.url && card.tabs.length > 0) {
|
|
const active = card.tabs.find((t: any) => t.id === card.activeTabId) || card.tabs[0];
|
|
card.url = active.url;
|
|
}
|
|
}
|
|
|
|
return {
|
|
cards: (layout.cards ?? {}) as Record<string, CardPosition>,
|
|
viewCards: (layout.view_cards ?? {}) as Record<string, ViewCardPosition>,
|
|
browserCards: browserCards as Record<string, BrowserCardPosition>,
|
|
expandedSessionIds: (layout.expanded_session_ids ?? []) as string[],
|
|
} satisfies LayoutPayload;
|
|
},
|
|
);
|
|
|
|
interface SaveLayoutPayload extends LayoutPayload {
|
|
dashboardId: string;
|
|
}
|
|
|
|
export const saveLayout = createAsyncThunk(
|
|
'dashboardLayout/save',
|
|
async (payload: SaveLayoutPayload) => {
|
|
await fetch(`${DASHBOARDS_API}/${payload.dashboardId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
layout: {
|
|
cards: payload.cards,
|
|
view_cards: payload.viewCards,
|
|
browser_cards: payload.browserCards,
|
|
expanded_session_ids: payload.expandedSessionIds,
|
|
},
|
|
}),
|
|
});
|
|
return payload;
|
|
},
|
|
);
|
|
|
|
interface Rect {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
}
|
|
|
|
function rectsOverlap(a: Rect, b: Rect): boolean {
|
|
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
}
|
|
|
|
function collectOccupiedRects(
|
|
state: DashboardLayoutState,
|
|
expandedSessionIds?: string[],
|
|
): Rect[] {
|
|
const expanded = new Set(expandedSessionIds);
|
|
const rects: Rect[] = [];
|
|
for (const c of Object.values(state.cards)) {
|
|
const h = expanded.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height;
|
|
rects.push({ x: c.x, y: c.y, w: c.width, h });
|
|
}
|
|
for (const c of Object.values(state.viewCards)) {
|
|
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
|
|
}
|
|
for (const c of Object.values(state.browserCards)) {
|
|
rects.push({ x: c.x, y: c.y, w: c.width, h: c.height });
|
|
}
|
|
return rects;
|
|
}
|
|
|
|
export function findOpenGridCell(
|
|
occupiedRects: Rect[],
|
|
newW: number,
|
|
newH: number,
|
|
): { x: number; y: number } {
|
|
const cellW = DEFAULT_CARD_W + GRID_GAP;
|
|
const cellH = DEFAULT_CARD_H + GRID_GAP;
|
|
const maxCols = Math.max(
|
|
1,
|
|
Math.floor((window.innerWidth - GRID_ORIGIN.x) / cellW) || GRID_COLS_FALLBACK,
|
|
);
|
|
|
|
for (let row = 0; ; row++) {
|
|
for (let col = 0; col < maxCols; col++) {
|
|
const x = GRID_ORIGIN.x + col * cellW;
|
|
const y = GRID_ORIGIN.y + row * cellH;
|
|
const candidate: Rect = { x, y, w: newW, h: newH };
|
|
if (!occupiedRects.some((r) => rectsOverlap(candidate, r))) {
|
|
return { x, y };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const dashboardLayoutSlice = createSlice({
|
|
name: 'dashboardLayout',
|
|
initialState,
|
|
reducers: {
|
|
setCardPosition(
|
|
state,
|
|
action: PayloadAction<{ sessionId: string; x: number; y: number }>
|
|
) {
|
|
const { sessionId, x, y } = action.payload;
|
|
const card = state.cards[sessionId];
|
|
if (card) {
|
|
card.x = x;
|
|
card.y = y;
|
|
}
|
|
},
|
|
|
|
setCardSize(
|
|
state,
|
|
action: PayloadAction<{ sessionId: string; width: number; height: number }>
|
|
) {
|
|
const { sessionId, width, height } = action.payload;
|
|
const card = state.cards[sessionId];
|
|
if (card) {
|
|
card.width = Math.max(480, width);
|
|
card.height = Math.max(180, height);
|
|
}
|
|
},
|
|
|
|
removeCard(state, action: PayloadAction<string>) {
|
|
delete state.cards[action.payload];
|
|
},
|
|
|
|
reconcileSessions(
|
|
state,
|
|
action: PayloadAction<{ sessionIds: string[]; expandedSessionIds: string[] }>,
|
|
) {
|
|
const { sessionIds, expandedSessionIds } = action.payload;
|
|
const liveIds = new Set(sessionIds);
|
|
|
|
for (const id of Object.keys(state.cards)) {
|
|
if (!liveIds.has(id)) {
|
|
delete state.cards[id];
|
|
}
|
|
}
|
|
|
|
const hasDraftCard = Object.keys(state.cards).some((id) => id.startsWith('draft-'));
|
|
const newIds = sessionIds.filter((id) => !state.cards[id]);
|
|
for (const id of newIds) {
|
|
if (hasDraftCard && !id.startsWith('draft-')) continue;
|
|
const rects = collectOccupiedRects(state, expandedSessionIds);
|
|
const pos = findOpenGridCell(rects, DEFAULT_CARD_W, DEFAULT_CARD_H);
|
|
state.cards[id] = {
|
|
session_id: id,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
width: DEFAULT_CARD_W,
|
|
height: DEFAULT_CARD_H,
|
|
};
|
|
}
|
|
},
|
|
|
|
tidyLayout(
|
|
state,
|
|
action: PayloadAction<{ expandedSessionIds: string[] }>,
|
|
) {
|
|
const expanded = new Set(action.payload.expandedSessionIds);
|
|
const agentCards = Object.values(state.cards);
|
|
const viewCards = Object.values(state.viewCards);
|
|
const bCards = Object.values(state.browserCards);
|
|
const total = agentCards.length + viewCards.length + bCards.length;
|
|
if (total === 0) return;
|
|
|
|
const allItems = [
|
|
...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedH: c.height })),
|
|
...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedH: c.height })),
|
|
...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedH: c.height })),
|
|
];
|
|
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
|
|
const placedRects: Rect[] = [];
|
|
|
|
for (const item of allItems) {
|
|
let w: number, h: number;
|
|
if (item.kind === 'agent') {
|
|
w = DEFAULT_CARD_W;
|
|
h = expanded.has(item.id) ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) : DEFAULT_CARD_H;
|
|
} else if (item.kind === 'view') {
|
|
w = DEFAULT_VIEW_CARD_W; h = DEFAULT_VIEW_CARD_H;
|
|
} else {
|
|
w = DEFAULT_BROWSER_CARD_W; h = DEFAULT_BROWSER_CARD_H;
|
|
}
|
|
|
|
const pos = findOpenGridCell(placedRects, w, h);
|
|
placedRects.push({ x: pos.x, y: pos.y, w, h });
|
|
|
|
if (item.kind === 'agent') {
|
|
const card = state.cards[item.id];
|
|
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
|
|
} else if (item.kind === 'view') {
|
|
const card = state.viewCards[item.id];
|
|
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
|
|
} else {
|
|
const card = state.browserCards[item.id];
|
|
if (card) { card.x = pos.x; card.y = pos.y; card.width = w; card.height = h; }
|
|
}
|
|
}
|
|
},
|
|
|
|
addViewCard(state, action: PayloadAction<{ outputId: string; expandedSessionIds?: string[] }>) {
|
|
const { outputId, expandedSessionIds } = action.payload;
|
|
if (state.viewCards[outputId]) return;
|
|
const rects = collectOccupiedRects(state, expandedSessionIds);
|
|
const pos = findOpenGridCell(rects, DEFAULT_VIEW_CARD_W, DEFAULT_VIEW_CARD_H);
|
|
state.viewCards[outputId] = {
|
|
output_id: outputId,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
width: DEFAULT_VIEW_CARD_W,
|
|
height: DEFAULT_VIEW_CARD_H,
|
|
};
|
|
},
|
|
|
|
setViewCardPosition(
|
|
state,
|
|
action: PayloadAction<{ outputId: string; x: number; y: number }>
|
|
) {
|
|
const { outputId, x, y } = action.payload;
|
|
const card = state.viewCards[outputId];
|
|
if (card) { card.x = x; card.y = y; }
|
|
},
|
|
|
|
setViewCardSize(
|
|
state,
|
|
action: PayloadAction<{ outputId: string; width: number; height: number }>
|
|
) {
|
|
const { outputId, width, height } = action.payload;
|
|
const card = state.viewCards[outputId];
|
|
if (card) {
|
|
card.width = Math.max(320, width);
|
|
card.height = Math.max(200, height);
|
|
}
|
|
},
|
|
|
|
removeViewCard(state, action: PayloadAction<string>) {
|
|
delete state.viewCards[action.payload];
|
|
},
|
|
|
|
addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[] }>) {
|
|
const id = `browser-${Date.now().toString(36)}`;
|
|
const tabId = generateTabId();
|
|
const rects = collectOccupiedRects(state, action.payload.expandedSessionIds);
|
|
const pos = findOpenGridCell(rects, DEFAULT_BROWSER_CARD_W, DEFAULT_BROWSER_CARD_H);
|
|
state.browserCards[id] = {
|
|
browser_id: id,
|
|
url: action.payload.url,
|
|
tabs: [{ id: tabId, url: action.payload.url, title: '' }],
|
|
activeTabId: tabId,
|
|
x: pos.x,
|
|
y: pos.y,
|
|
width: DEFAULT_BROWSER_CARD_W,
|
|
height: DEFAULT_BROWSER_CARD_H,
|
|
};
|
|
},
|
|
|
|
addBrowserCardFromBackend(state, action: PayloadAction<BrowserCardPosition>) {
|
|
const card = action.payload;
|
|
if (state.browserCards[card.browser_id]) return;
|
|
state.browserCards[card.browser_id] = {
|
|
...card,
|
|
width: card.width || DEFAULT_BROWSER_CARD_W,
|
|
height: card.height || DEFAULT_BROWSER_CARD_H,
|
|
};
|
|
},
|
|
|
|
setBrowserCardPosition(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; x: number; y: number }>
|
|
) {
|
|
const { browserId, x, y } = action.payload;
|
|
const card = state.browserCards[browserId];
|
|
if (card) { card.x = x; card.y = y; }
|
|
},
|
|
|
|
setBrowserCardSize(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; width: number; height: number }>
|
|
) {
|
|
const { browserId, width, height } = action.payload;
|
|
const card = state.browserCards[browserId];
|
|
if (card) {
|
|
card.width = Math.max(400, width);
|
|
card.height = Math.max(300, height);
|
|
}
|
|
},
|
|
|
|
removeBrowserCard(state, action: PayloadAction<string>) {
|
|
delete state.browserCards[action.payload];
|
|
},
|
|
|
|
updateBrowserCardUrl(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; url: string }>
|
|
) {
|
|
const card = state.browserCards[action.payload.browserId];
|
|
if (card) {
|
|
card.url = action.payload.url;
|
|
const tab = card.tabs.find((t) => t.id === card.activeTabId);
|
|
if (tab) tab.url = action.payload.url;
|
|
}
|
|
},
|
|
|
|
addBrowserTab(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; url: string; makeActive?: boolean }>
|
|
) {
|
|
const card = state.browserCards[action.payload.browserId];
|
|
if (!card) return;
|
|
const tabId = generateTabId();
|
|
card.tabs.push({ id: tabId, url: action.payload.url, title: '' });
|
|
if (action.payload.makeActive !== false) {
|
|
card.activeTabId = tabId;
|
|
card.url = action.payload.url;
|
|
}
|
|
},
|
|
|
|
removeBrowserTab(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; tabId: string }>
|
|
) {
|
|
const card = state.browserCards[action.payload.browserId];
|
|
if (!card) return;
|
|
const idx = card.tabs.findIndex((t) => t.id === action.payload.tabId);
|
|
if (idx === -1) return;
|
|
card.tabs.splice(idx, 1);
|
|
if (card.tabs.length === 0) {
|
|
delete state.browserCards[action.payload.browserId];
|
|
return;
|
|
}
|
|
if (card.activeTabId === action.payload.tabId) {
|
|
const newActive = card.tabs[Math.min(idx, card.tabs.length - 1)];
|
|
card.activeTabId = newActive.id;
|
|
card.url = newActive.url;
|
|
}
|
|
},
|
|
|
|
setActiveBrowserTab(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; tabId: string }>
|
|
) {
|
|
const card = state.browserCards[action.payload.browserId];
|
|
if (!card) return;
|
|
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
|
|
if (tab) {
|
|
card.activeTabId = tab.id;
|
|
card.url = tab.url;
|
|
}
|
|
},
|
|
|
|
updateBrowserTabUrl(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; tabId: string; url: string }>
|
|
) {
|
|
const card = state.browserCards[action.payload.browserId];
|
|
if (!card) return;
|
|
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
|
|
if (tab) {
|
|
tab.url = action.payload.url;
|
|
if (action.payload.tabId === card.activeTabId) {
|
|
card.url = action.payload.url;
|
|
}
|
|
}
|
|
},
|
|
|
|
updateBrowserTabTitle(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; tabId: string; title: string }>
|
|
) {
|
|
const card = state.browserCards[action.payload.browserId];
|
|
if (!card) return;
|
|
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
|
|
if (tab) tab.title = action.payload.title;
|
|
},
|
|
|
|
updateBrowserTabFavicon(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; tabId: string; favicon: string }>
|
|
) {
|
|
const card = state.browserCards[action.payload.browserId];
|
|
if (!card) return;
|
|
const tab = card.tabs.find((t) => t.id === action.payload.tabId);
|
|
if (tab) tab.favicon = action.payload.favicon;
|
|
},
|
|
|
|
reorderBrowserTab(
|
|
state,
|
|
action: PayloadAction<{ browserId: string; tabId: string; toIndex: number }>
|
|
) {
|
|
const card = state.browserCards[action.payload.browserId];
|
|
if (!card) return;
|
|
const fromIdx = card.tabs.findIndex((t) => t.id === action.payload.tabId);
|
|
if (fromIdx === -1) return;
|
|
const [tab] = card.tabs.splice(fromIdx, 1);
|
|
card.tabs.splice(Math.max(0, Math.min(action.payload.toIndex, card.tabs.length)), 0, tab);
|
|
},
|
|
|
|
moveCards(
|
|
state,
|
|
action: PayloadAction<{
|
|
items: Array<{ id: string; type: 'agent' | 'view' | 'browser' }>;
|
|
dx: number;
|
|
dy: number;
|
|
}>,
|
|
) {
|
|
const { items, dx, dy } = action.payload;
|
|
for (const item of items) {
|
|
if (item.type === 'agent') {
|
|
const card = state.cards[item.id];
|
|
if (card) {
|
|
card.x += dx;
|
|
card.y += dy;
|
|
}
|
|
} else if (item.type === 'view') {
|
|
const card = state.viewCards[item.id];
|
|
if (card) {
|
|
card.x += dx;
|
|
card.y += dy;
|
|
}
|
|
} else {
|
|
const card = state.browserCards[item.id];
|
|
if (card) {
|
|
card.x += dx;
|
|
card.y += dy;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
replaceDraftId(
|
|
state,
|
|
action: PayloadAction<{ oldId: string; newId: string }>
|
|
) {
|
|
const { oldId, newId } = action.payload;
|
|
const card = state.cards[oldId];
|
|
if (card) {
|
|
delete state.cards[oldId];
|
|
state.cards[newId] = { ...card, session_id: newId };
|
|
}
|
|
},
|
|
|
|
setGlowingBrowserCards(
|
|
state,
|
|
action: PayloadAction<{ browserIds: string[]; sessionId: string }>
|
|
) {
|
|
const { browserIds, sessionId } = action.payload;
|
|
for (const id of browserIds) {
|
|
state.glowingBrowserCards[id] = sessionId;
|
|
}
|
|
},
|
|
|
|
clearGlowingBrowserCards(state, action: PayloadAction<string>) {
|
|
const sessionId = action.payload;
|
|
for (const [browserId, sid] of Object.entries(state.glowingBrowserCards)) {
|
|
if (sid === sessionId) delete state.glowingBrowserCards[browserId];
|
|
}
|
|
},
|
|
|
|
clearAllGlowingBrowserCards(state) {
|
|
state.glowingBrowserCards = {};
|
|
},
|
|
|
|
resetLayout(state) {
|
|
state.cards = {};
|
|
state.viewCards = {};
|
|
state.browserCards = {};
|
|
state.glowingBrowserCards = {};
|
|
state.persistedExpandedSessionIds = [];
|
|
state.initialized = false;
|
|
},
|
|
|
|
},
|
|
extraReducers: (builder) => {
|
|
builder
|
|
.addCase(fetchLayout.pending, (state) => {
|
|
state.loading = true;
|
|
})
|
|
.addCase(fetchLayout.fulfilled, (state, action) => {
|
|
state.loading = false;
|
|
state.initialized = true;
|
|
state.cards = action.payload.cards;
|
|
state.viewCards = action.payload.viewCards;
|
|
state.browserCards = action.payload.browserCards;
|
|
state.persistedExpandedSessionIds = action.payload.expandedSessionIds;
|
|
})
|
|
.addCase(fetchLayout.rejected, (state) => {
|
|
state.loading = false;
|
|
state.initialized = true;
|
|
})
|
|
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
|
|
const { draftId, session } = action.payload;
|
|
const card = state.cards[draftId];
|
|
if (card) {
|
|
delete state.cards[draftId];
|
|
state.cards[session.id] = { ...card, session_id: session.id };
|
|
}
|
|
});
|
|
},
|
|
});
|
|
|
|
export const {
|
|
setCardPosition,
|
|
setCardSize,
|
|
removeCard,
|
|
reconcileSessions,
|
|
replaceDraftId,
|
|
tidyLayout,
|
|
addViewCard,
|
|
setViewCardPosition,
|
|
setViewCardSize,
|
|
removeViewCard,
|
|
addBrowserCard,
|
|
addBrowserCardFromBackend,
|
|
setBrowserCardPosition,
|
|
setBrowserCardSize,
|
|
removeBrowserCard,
|
|
updateBrowserCardUrl,
|
|
addBrowserTab,
|
|
removeBrowserTab,
|
|
setActiveBrowserTab,
|
|
updateBrowserTabUrl,
|
|
updateBrowserTabTitle,
|
|
updateBrowserTabFavicon,
|
|
reorderBrowserTab,
|
|
moveCards,
|
|
setGlowingBrowserCards,
|
|
clearGlowingBrowserCards,
|
|
clearAllGlowingBrowserCards,
|
|
resetLayout,
|
|
} = dashboardLayoutSlice.actions;
|
|
|
|
export default dashboardLayoutSlice.reducer;
|