feat: refactor artifact analysis workspace

This commit is contained in:
2026-08-19 16:09:51 +08:00
parent bfadc95f93
commit d2f2895c36
26 changed files with 998 additions and 2278 deletions
@@ -1,19 +1,11 @@
export const MAX_ANALYSIS_WINDOWS = 6;
export const ARTIFACT_MAP_SPLIT_MIN_WIDTH = 1360;
export type WorkspaceRect = {
x: number;
y: number;
width: number;
height: number;
};
export type WorkspaceBounds = {
width: number;
height: number;
};
export type WorkspaceWindowMode = "docked" | "floating" | "maximized" | "minimized";
export type WorkspaceLayoutMode = "free" | "primary" | "split" | "grid" | "cascade";
export type ArtifactType = "diagnosis" | "simulation" | "metric_analysis";
export type ArtifactLifecycle = "draft" | "reviewed" | "published" | "archived";
export type ArtifactViewMode = "map_split" | "focus";
export type ArtifactRequestedView = ArtifactViewMode | "map_only";
export type ArtifactMapRelation = "required" | "optional" | "none";
export type WorkbenchSurfaceMode = "default" | ArtifactRequestedView;
export type AnalysisMetric = {
label: string;
@@ -78,548 +70,100 @@ export type AnalysisBlock =
paragraphs: string[];
};
export type AnalysisDocument = {
export type AnalysisArtifactAction = {
id: string;
label: string;
description: string;
tone: "primary" | "neutral" | "warning";
};
export type AnalysisArtifact = {
id: string;
type: ArtifactType;
lifecycle: ArtifactLifecycle;
revision: number;
title: string;
subtitle: string;
summary: string;
scope: string;
confidence: string;
generatedAt: string;
preferredView: ArtifactViewMode;
mapRelation: ArtifactMapRelation;
blocks: AnalysisBlock[];
sources: string[];
limitations: string[];
actions: AnalysisArtifactAction[];
};
type WorkspaceWindowState = {
mode: WorkspaceWindowMode;
rect: WorkspaceRect;
restoreRect: WorkspaceRect;
zIndex: number;
minimizedByLayout: boolean;
export type ArtifactWorkspaceState = {
pendingArtifact: AnalysisArtifact | null;
activeArtifact: AnalysisArtifact | null;
requestedView: ArtifactRequestedView;
};
export type AnalysisWorkspaceWindow = WorkspaceWindowState & {
id: string;
kind: "agent-analysis";
title: string;
document: AnalysisDocument;
};
export type MapWorkspaceWindow = WorkspaceWindowState & {
id: "workspace-map";
kind: "map";
title: string;
};
export type WorkspaceState = {
map: MapWorkspaceWindow;
analyses: AnalysisWorkspaceWindow[];
activeWindowId: string;
nextZIndex: number;
layout: {
mode: WorkspaceLayoutMode;
orderedWindowIds: string[];
};
};
const FALLBACK_BOUNDS: WorkspaceBounds = { width: 960, height: 680 };
const MAP_WINDOW_ID = "workspace-map";
const LAYOUT_INSET = 8;
const LAYOUT_GAP = 8;
export function createInitialWorkspaceState(bounds: WorkspaceBounds = FALLBACK_BOUNDS): WorkspaceState {
const mapRect = getDefaultMapRect(bounds);
export function createInitialArtifactWorkspaceState(): ArtifactWorkspaceState {
return {
map: {
id: MAP_WINDOW_ID,
kind: "map",
title: "供水管网地图",
mode: "docked",
rect: mapRect,
restoreRect: mapRect,
zIndex: 1,
minimizedByLayout: false
},
analyses: [],
activeWindowId: MAP_WINDOW_ID,
nextZIndex: 2,
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
pendingArtifact: null,
activeArtifact: null,
requestedView: "map_split"
};
}
export function openAnalysisDocument(
state: WorkspaceState,
document: AnalysisDocument,
bounds: WorkspaceBounds
): WorkspaceState {
if (state.analyses.length >= MAX_ANALYSIS_WINDOWS) return state;
export function previewAnalysisArtifact(
state: ArtifactWorkspaceState,
artifact: AnalysisArtifact
): ArtifactWorkspaceState {
return { ...state, pendingArtifact: artifact };
}
const rect = getDefaultAnalysisRect(bounds, state.analyses.length);
const window: AnalysisWorkspaceWindow = {
id: document.id,
kind: "agent-analysis",
title: document.title,
mode: "floating",
rect,
restoreRect: rect,
zIndex: state.nextZIndex,
minimizedByLayout: false,
document
export function openPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
if (!state.pendingArtifact) return state;
return {
pendingArtifact: null,
activeArtifact: state.pendingArtifact,
requestedView: getPreferredArtifactView(state.pendingArtifact)
};
const opened: WorkspaceState = {
...state,
analyses: [...state.analyses, window],
activeWindowId: window.id,
nextZIndex: state.nextZIndex + 1,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([window.id, MAP_WINDOW_ID, ...state.layout.orderedWindowIds])
}
};
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(opened, bounds);
return opened;
}
export function closeAnalysisWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds = FALLBACK_BOUNDS
): WorkspaceState {
const analyses = state.analyses.filter((window) => window.id !== windowId);
if (analyses.length === state.analyses.length) return state;
if (analyses.length === 0) {
return {
...state,
map: {
...state.map,
mode: "docked",
minimizedByLayout: false,
zIndex: state.nextZIndex
},
analyses,
activeWindowId: MAP_WINDOW_ID,
nextZIndex: state.nextZIndex + 1,
layout: { mode: "free", orderedWindowIds: [MAP_WINDOW_ID] }
};
}
const orderedWindowIds = state.layout.orderedWindowIds.filter((id) => id !== windowId);
const nextActive = state.activeWindowId === windowId
? orderedWindowIds.find((id) => getWindowById({ ...state, analyses }, id)?.mode !== "minimized")
: state.activeWindowId;
const closed: WorkspaceState = {
...state,
analyses,
activeWindowId: nextActive ?? MAP_WINDOW_ID,
layout: { ...state.layout, orderedWindowIds }
};
return state.layout.mode === "free" ? closed : arrangeWorkspaceLayout(closed, bounds);
}
export function focusWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (!getWindowById(state, windowId)) return state;
return updateWindowById(
{ ...state, activeWindowId: windowId, nextZIndex: state.nextZIndex + 1 },
windowId,
(window) => ({ ...window, zIndex: state.nextZIndex })
);
}
export function minimizeWorkspaceWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds = FALLBACK_BOUNDS
): WorkspaceState {
if (!getWindowById(state, windowId)) return state;
const minimized = updateWindowById(state, windowId, (window) => ({
...window,
mode: "minimized",
minimizedByLayout: false
}));
return state.layout.mode === "free" ? minimized : arrangeWorkspaceLayout(minimized, bounds);
}
export function maximizeWorkspaceWindow(state: WorkspaceState, windowId: string): WorkspaceState {
if (!getWindowById(state, windowId)) return state;
return focusWorkspaceWindow(
updateWindowById(state, windowId, (window) => ({
...window,
mode: "maximized",
minimizedByLayout: false
})),
windowId
);
}
export function restoreWorkspaceWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds
): WorkspaceState {
const window = getWindowById(state, windowId);
if (!window) return state;
if (state.layout.mode !== "free") {
const restored = updateWindowById(
{
...state,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
}
},
windowId,
(current) => ({ ...current, mode: "floating", minimizedByLayout: false })
);
return focusWorkspaceWindow(arrangeWorkspaceLayout(restored, bounds), windowId);
}
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
const rect = clampWorkspaceRect(window.restoreRect, bounds, kind);
return focusWorkspaceWindow(
updateWindowById(state, windowId, (current) => ({
...current,
mode: "floating",
rect,
minimizedByLayout: false
})),
windowId
);
}
export function updateWorkspaceWindowRect(
state: WorkspaceState,
windowId: string,
rect: WorkspaceRect,
bounds: WorkspaceBounds
): WorkspaceState {
if (!getWindowById(state, windowId)) return state;
const kind = windowId === MAP_WINDOW_ID ? "map" : "analysis";
const nextRect = clampWorkspaceRect(rect, bounds, kind);
return updateWindowById(
{
...state,
layout: { ...state.layout, mode: "free" }
},
windowId,
(window) => ({
...window,
mode: "floating",
rect: nextRect,
restoreRect: nextRect,
minimizedByLayout: false
})
);
}
export function applyWorkspaceLayout(
state: WorkspaceState,
mode: WorkspaceLayoutMode,
bounds: WorkspaceBounds
): WorkspaceState {
if (mode === "free") {
return { ...state, layout: { ...state.layout, mode } };
}
const orderedWindowIds = uniqueIds([
state.activeWindowId,
MAP_WINDOW_ID,
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id),
...state.layout.orderedWindowIds
]);
const restored = mapAllWorkspaceWindows(
{
...state,
layout: { mode, orderedWindowIds }
},
(window) => ({ ...window, mode: "floating", minimizedByLayout: false })
);
return arrangeWorkspaceLayout(restored, bounds, true);
}
export function setWorkspacePrimaryWindow(
state: WorkspaceState,
windowId: string,
bounds: WorkspaceBounds
): WorkspaceState {
if (!getWindowById(state, windowId) || state.layout.mode === "free") return state;
const next = {
...state,
layout: {
...state.layout,
orderedWindowIds: uniqueIds([windowId, ...state.layout.orderedWindowIds])
}
};
return focusWorkspaceWindow(arrangeWorkspaceLayout(next, bounds), windowId);
}
export function swapWorkspaceWindows(
state: WorkspaceState,
sourceId: string,
targetId: string,
bounds: WorkspaceBounds
): WorkspaceState {
const source = getWindowById(state, sourceId);
const target = getWindowById(state, targetId);
if (!source || !target || sourceId === targetId || source.mode === "minimized" || target.mode === "minimized") {
return state;
}
if (state.layout.mode !== "free") {
const order = normalizeWindowOrder(state);
const sourceIndex = order.indexOf(sourceId);
const targetIndex = order.indexOf(targetId);
if (sourceIndex < 0 || targetIndex < 0) return state;
[order[sourceIndex], order[targetIndex]] = [order[targetIndex], order[sourceIndex]];
const swapped = { ...state, layout: { ...state.layout, orderedWindowIds: order } };
return focusWorkspaceWindow(arrangeWorkspaceLayout(swapped, bounds), sourceId);
}
const swapped = updateWindowById(
updateWindowById(state, sourceId, (window) => ({
...window,
mode: target.mode,
rect: target.rect,
restoreRect: target.restoreRect,
minimizedByLayout: false
})),
targetId,
(window) => ({
...window,
mode: source.mode,
rect: source.rect,
restoreRect: source.restoreRect,
minimizedByLayout: false
})
);
return focusWorkspaceWindow(swapped, sourceId);
}
export function resizeWorkspace(state: WorkspaceState, bounds: WorkspaceBounds): WorkspaceState {
if (state.layout.mode !== "free") return arrangeWorkspaceLayout(state, bounds);
export function collapseActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
if (!state.activeArtifact) return state;
return {
...state,
map: {
...state.map,
rect: clampWorkspaceRect(state.map.rect, bounds, "map"),
restoreRect: clampWorkspaceRect(state.map.restoreRect, bounds, "map")
},
analyses: state.analyses.map((window) => ({
...window,
rect: clampWorkspaceRect(window.rect, bounds, "analysis"),
restoreRect: clampWorkspaceRect(window.restoreRect, bounds, "analysis")
}))
pendingArtifact: state.activeArtifact,
activeArtifact: null
};
}
export function clampWorkspaceRect(
rect: WorkspaceRect,
bounds: WorkspaceBounds,
kind: "map" | "analysis"
): WorkspaceRect {
const minimumWidth = kind === "map" ? 320 : 360;
const minimumHeight = kind === "map" ? 240 : 280;
const availableWidth = Math.max(280, bounds.width - 24);
const availableHeight = Math.max(240, bounds.height - 24);
const width = clamp(rect.width, Math.min(minimumWidth, availableWidth), availableWidth);
const height = clamp(rect.height, Math.min(minimumHeight, availableHeight), availableHeight);
return {
x: clamp(rect.x, 12, Math.max(12, bounds.width - width - 12)),
y: clamp(rect.y, 12, Math.max(12, bounds.height - height - 12)),
width,
height
};
export function destroyPendingArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
return state.pendingArtifact ? { ...state, pendingArtifact: null } : state;
}
function arrangeWorkspaceLayout(
state: WorkspaceState,
bounds: WorkspaceBounds,
restoreAll = false
): WorkspaceState {
if (state.layout.mode === "free") return state;
const orderedWindowIds = normalizeWindowOrder(state);
const eligibleIds = orderedWindowIds.filter((id) => {
const window = getWindowById(state, id);
return window && (restoreAll || window.mode !== "minimized" || window.minimizedByLayout);
});
const visibleIds = selectVisibleWindowIds(eligibleIds, state.layout.mode);
const visibleSet = new Set(visibleIds);
const eligibleSet = new Set(eligibleIds);
const rects = getLayoutRects(state.layout.mode, visibleIds.length, bounds);
let next = mapAllWorkspaceWindows(state, (window) => {
const visibleIndex = visibleIds.indexOf(window.id);
if (visibleIndex >= 0) {
const rect = rects[visibleIndex] ?? window.rect;
return {
...window,
mode: !restoreAll && window.mode === "maximized" ? "maximized" : "floating",
rect,
restoreRect: rect,
minimizedByLayout: false
};
}
if (eligibleSet.has(window.id) && !visibleSet.has(window.id)) {
return { ...window, mode: "minimized", minimizedByLayout: true };
}
return window;
});
const activeWindowId = visibleSet.has(next.activeWindowId)
? next.activeWindowId
: visibleIds[0] ?? next.activeWindowId;
next = {
...next,
activeWindowId,
layout: { ...next.layout, orderedWindowIds }
};
return next;
export function destroyActiveArtifact(state: ArtifactWorkspaceState): ArtifactWorkspaceState {
return state.activeArtifact
? { ...state, activeArtifact: null, requestedView: "map_split" }
: state;
}
function selectVisibleWindowIds(ids: string[], mode: WorkspaceLayoutMode) {
const capacity = mode === "split" ? 2 : mode === "primary" ? 3 : ids.length;
const selected = ids.slice(0, capacity);
if (
(mode === "split" || mode === "primary") &&
ids.includes(MAP_WINDOW_ID) &&
!selected.includes(MAP_WINDOW_ID)
) {
selected[selected.length - 1] = MAP_WINDOW_ID;
}
return selected;
export function requestArtifactView(
state: ArtifactWorkspaceState,
view: ArtifactRequestedView
): ArtifactWorkspaceState {
if (!state.activeArtifact) return state;
return { ...state, requestedView: view };
}
function getLayoutRects(mode: WorkspaceLayoutMode, count: number, bounds: WorkspaceBounds): WorkspaceRect[] {
if (count === 0) return [];
const width = Math.max(1, bounds.width - LAYOUT_INSET * 2);
const height = Math.max(1, bounds.height - LAYOUT_INSET * 2);
if (mode === "primary") {
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }];
const primaryWidth = Math.floor((width - LAYOUT_GAP) * 0.68);
const secondaryWidth = width - LAYOUT_GAP - primaryWidth;
const secondaryHeight = count === 2 ? height : Math.floor((height - LAYOUT_GAP) / 2);
return [
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: primaryWidth, height },
{ x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP, y: LAYOUT_INSET, width: secondaryWidth, height: secondaryHeight },
...(count === 3
? [{
x: LAYOUT_INSET + primaryWidth + LAYOUT_GAP,
y: LAYOUT_INSET + secondaryHeight + LAYOUT_GAP,
width: secondaryWidth,
height: height - secondaryHeight - LAYOUT_GAP
}]
: [])
];
}
if (mode === "split") {
if (count === 1) return [{ x: LAYOUT_INSET, y: LAYOUT_INSET, width, height }];
const firstWidth = Math.floor((width - LAYOUT_GAP) / 2);
return [
{ x: LAYOUT_INSET, y: LAYOUT_INSET, width: firstWidth, height },
{
x: LAYOUT_INSET + firstWidth + LAYOUT_GAP,
y: LAYOUT_INSET,
width: width - firstWidth - LAYOUT_GAP,
height
}
];
}
if (mode === "cascade") {
const windowWidth = Math.max(280, Math.floor(width * 0.78));
const windowHeight = Math.max(240, Math.floor(height * 0.78));
return Array.from({ length: count }, (_, index) => ({
x: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.width - windowWidth - LAYOUT_INSET)),
y: Math.min(LAYOUT_INSET + index * 28, Math.max(LAYOUT_INSET, bounds.height - windowHeight - LAYOUT_INSET)),
width: Math.min(windowWidth, width),
height: Math.min(windowHeight, height)
}));
}
const columns = count === 1 ? 1 : count <= 4 ? 2 : 3;
const rows = Math.ceil(count / columns);
const cellWidth = Math.floor((width - LAYOUT_GAP * (columns - 1)) / columns);
const cellHeight = Math.floor((height - LAYOUT_GAP * (rows - 1)) / rows);
return Array.from({ length: count }, (_, index) => {
const column = index % columns;
const row = Math.floor(index / columns);
return {
x: LAYOUT_INSET + column * (cellWidth + LAYOUT_GAP),
y: LAYOUT_INSET + row * (cellHeight + LAYOUT_GAP),
width: column === columns - 1 ? width - column * (cellWidth + LAYOUT_GAP) : cellWidth,
height: row === rows - 1 ? height - row * (cellHeight + LAYOUT_GAP) : cellHeight
};
});
export function resolveWorkbenchSurfaceMode(
state: ArtifactWorkspaceState,
viewportWidth: number
): WorkbenchSurfaceMode {
const artifact = state.activeArtifact;
if (!artifact) return "default";
if (state.requestedView === "map_only") return "map_only";
if (artifact.mapRelation === "none" || state.requestedView === "focus") return "focus";
return viewportWidth >= ARTIFACT_MAP_SPLIT_MIN_WIDTH ? "map_split" : "focus";
}
function normalizeWindowOrder(state: WorkspaceState) {
const ids = new Set(allWorkspaceWindows(state).map((window) => window.id));
return uniqueIds([
...state.layout.orderedWindowIds,
...allWorkspaceWindows(state).sort((a, b) => b.zIndex - a.zIndex).map((window) => window.id)
]).filter((id) => ids.has(id));
}
function getWindowById(state: WorkspaceState, windowId: string): MapWorkspaceWindow | AnalysisWorkspaceWindow | undefined {
return windowId === MAP_WINDOW_ID ? state.map : state.analyses.find((window) => window.id === windowId);
}
function updateWindowById(
state: WorkspaceState,
windowId: string,
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
): WorkspaceState {
if (windowId === MAP_WINDOW_ID) {
return { ...state, map: update(state.map) };
}
return {
...state,
analyses: state.analyses.map((window) => window.id === windowId ? update(window) : window)
};
}
function mapAllWorkspaceWindows(
state: WorkspaceState,
update: <T extends MapWorkspaceWindow | AnalysisWorkspaceWindow>(window: T) => T
): WorkspaceState {
return {
...state,
map: update(state.map),
analyses: state.analyses.map((window) => update(window))
};
}
function allWorkspaceWindows(state: WorkspaceState) {
return [state.map, ...state.analyses] as Array<MapWorkspaceWindow | AnalysisWorkspaceWindow>;
}
function uniqueIds(ids: string[]) {
return [...new Set(ids)];
}
function getDefaultAnalysisRect(bounds: WorkspaceBounds, index: number): WorkspaceRect {
const width = Math.min(Math.max(bounds.width * 0.8, 640), Math.max(640, bounds.width - 48));
const height = Math.min(Math.max(bounds.height * 0.84, 420), Math.max(420, bounds.height - 48));
const cascade = (index % 5) * 28;
return clampWorkspaceRect(
{ x: 24 + cascade, y: 20 + cascade, width, height },
bounds,
"analysis"
);
}
function getDefaultMapRect(bounds: WorkspaceBounds): WorkspaceRect {
return clampWorkspaceRect(
{
x: Math.max(12, bounds.width * 0.36),
y: Math.max(12, bounds.height * 0.18),
width: bounds.width * 0.6,
height: bounds.height * 0.72
},
bounds,
"map"
);
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
function getPreferredArtifactView(artifact: AnalysisArtifact): ArtifactViewMode {
return artifact.mapRelation === "none" ? "focus" : artifact.preferredView;
}