Implement Phase 4: tools, God Mode, and missing features
Backend: - Add Tavily web search tool wrapper (tools/web_search.py) - Add PDF reader + ChromaDB vector store tool (tools/pdf_reader.py) - Bind tools to LLM calls via .bind_tools() in dynamic_graph_builder - Implement God Mode using LangGraph interrupt_before + MemorySaver - Add approve/reject/modify API endpoints for God Mode - Add PDF upload endpoint with ingestion pipeline - Add persistent run history (CouncilRun model + run_service + API) - Add Alembic migration for council_runs table - Enhance WebSocket to emit run_paused and run_resumed events - Add tests for tools, God Mode, and run history Frontend: - Add God Mode approval UI (GodModePanel component) - Add Auto-Pilot / God Mode toggle in Konferenzzimmer - Add functional PDF upload handler - Add Conditional Edge editor (EdgeSettingsPanel component) - Add edge click selection in ArchitectCanvas - Update Zustand store with edge selection and update actions - Update types for God Mode, execution modes, and WS events - Update API client with God Mode, PDF upload, and blueprint run endpoints - Update WebSocket hook for paused/resumed events - Add Vitest config and frontend tests (store, parser, types, API) https://claude.ai/code/session_017U6idFgaqnYTXzPxA7mxMv
This commit is contained in:
parent
c6d0c4a636
commit
001649a364
31 changed files with 2502 additions and 81 deletions
34
frontend/app/__tests__/api-client.test.ts
Normal file
34
frontend/app/__tests__/api-client.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { wsUrl } from "@/app/utils/api-client";
|
||||
|
||||
describe("wsUrl", () => {
|
||||
it("should convert http to ws", () => {
|
||||
const url = wsUrl("test-run-id");
|
||||
expect(url).toBe("ws://localhost:8000/ws/council/test-run-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("API client types", () => {
|
||||
it("should export runApi with expected methods", async () => {
|
||||
const { runApi } = await import("@/app/utils/api-client");
|
||||
expect(runApi.start).toBeDefined();
|
||||
expect(runApi.startFromBlueprint).toBeDefined();
|
||||
expect(runApi.status).toBeDefined();
|
||||
expect(runApi.approve).toBeDefined();
|
||||
expect(runApi.getState).toBeDefined();
|
||||
});
|
||||
|
||||
it("should export councilApi with expected methods", async () => {
|
||||
const { councilApi } = await import("@/app/utils/api-client");
|
||||
expect(councilApi.list).toBeDefined();
|
||||
expect(councilApi.get).toBeDefined();
|
||||
expect(councilApi.create).toBeDefined();
|
||||
expect(councilApi.update).toBeDefined();
|
||||
expect(councilApi.delete).toBeDefined();
|
||||
});
|
||||
|
||||
it("should export pdfApi with upload method", async () => {
|
||||
const { pdfApi } = await import("@/app/utils/api-client");
|
||||
expect(pdfApi.upload).toBeDefined();
|
||||
});
|
||||
});
|
||||
148
frontend/app/__tests__/blueprint-parser.test.ts
Normal file
148
frontend/app/__tests__/blueprint-parser.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { parseGraphToBlueprint, parseBlueprintToGraph } from "@/app/utils/blueprint-parser";
|
||||
import { Node, Edge } from "@xyflow/react";
|
||||
import { AgentNodeData, CouncilBlueprint } from "@/app/types/council";
|
||||
|
||||
describe("parseGraphToBlueprint", () => {
|
||||
it("should convert React Flow nodes and edges to blueprint format", () => {
|
||||
const nodes: Node<AgentNodeData>[] = [
|
||||
{
|
||||
id: "n1",
|
||||
type: "agentNode",
|
||||
position: { x: 100, y: 200 },
|
||||
data: {
|
||||
label: "Master Agent",
|
||||
systemPrompt: "You are the master writer.",
|
||||
model: "claude-3-5-sonnet",
|
||||
tools: { webSearch: true, pdfReader: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "n2",
|
||||
type: "agentNode",
|
||||
position: { x: 400, y: 200 },
|
||||
data: {
|
||||
label: "Critic Agent",
|
||||
systemPrompt: "You evaluate drafts.",
|
||||
model: "gpt-4o",
|
||||
tools: { webSearch: false, pdfReader: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: "e1",
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
type: "default",
|
||||
data: { type: "linear" },
|
||||
},
|
||||
];
|
||||
|
||||
const blueprint = parseGraphToBlueprint(nodes, edges, "Test Council");
|
||||
|
||||
expect(blueprint.version).toBe(1);
|
||||
expect(blueprint.name).toBe("Test Council");
|
||||
expect(blueprint.nodes).toHaveLength(2);
|
||||
expect(blueprint.edges).toHaveLength(1);
|
||||
|
||||
expect(blueprint.nodes[0].label).toBe("Master Agent");
|
||||
expect(blueprint.nodes[0].tools.webSearch).toBe(true);
|
||||
expect(blueprint.nodes[1].model).toBe("gpt-4o");
|
||||
|
||||
expect(blueprint.edges[0].type).toBe("linear");
|
||||
expect(blueprint.edges[0].source).toBe("n1");
|
||||
expect(blueprint.edges[0].target).toBe("n2");
|
||||
});
|
||||
|
||||
it("should handle conditional edges with condition labels", () => {
|
||||
const nodes: Node<AgentNodeData>[] = [
|
||||
{
|
||||
id: "n1",
|
||||
type: "agentNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: "A",
|
||||
systemPrompt: "",
|
||||
model: "claude-3-5-sonnet",
|
||||
tools: { webSearch: false, pdfReader: false },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const edges: Edge[] = [
|
||||
{
|
||||
id: "e1",
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
type: "conditionalEdge",
|
||||
data: { type: "conditional", condition: "approve" },
|
||||
},
|
||||
];
|
||||
|
||||
const blueprint = parseGraphToBlueprint(nodes, edges, "Test");
|
||||
expect(blueprint.edges[0].type).toBe("conditional");
|
||||
expect(blueprint.edges[0].condition).toBe("approve");
|
||||
});
|
||||
|
||||
it("should preserve existing blueprint ID", () => {
|
||||
const blueprint = parseGraphToBlueprint([], [], "Test", "existing-id");
|
||||
expect(blueprint.id).toBe("existing-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueprintToGraph", () => {
|
||||
it("should convert blueprint back to React Flow format", () => {
|
||||
const blueprint: CouncilBlueprint = {
|
||||
version: 1,
|
||||
name: "Test",
|
||||
nodes: [
|
||||
{
|
||||
id: "n1",
|
||||
label: "Master",
|
||||
systemPrompt: "You are the master.",
|
||||
model: "claude-3-5-sonnet",
|
||||
tools: { webSearch: true, pdfReader: false },
|
||||
position: { x: 100, y: 200 },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "e1",
|
||||
source: "n1",
|
||||
target: "n2",
|
||||
type: "conditional",
|
||||
condition: "rework",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { nodes, edges } = parseBlueprintToGraph(blueprint);
|
||||
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].type).toBe("agentNode");
|
||||
expect(nodes[0].data.label).toBe("Master");
|
||||
expect(nodes[0].data.tools.webSearch).toBe(true);
|
||||
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].type).toBe("conditionalEdge");
|
||||
expect(edges[0].data?.condition).toBe("rework");
|
||||
expect(edges[0].animated).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle linear edges", () => {
|
||||
const blueprint: CouncilBlueprint = {
|
||||
version: 1,
|
||||
name: "Test",
|
||||
nodes: [],
|
||||
edges: [
|
||||
{ id: "e1", source: "a", target: "b", type: "linear" },
|
||||
],
|
||||
};
|
||||
|
||||
const { edges } = parseBlueprintToGraph(blueprint);
|
||||
expect(edges[0].type).toBe("default");
|
||||
expect(edges[0].animated).toBe(false);
|
||||
});
|
||||
});
|
||||
178
frontend/app/__tests__/council-store.test.ts
Normal file
178
frontend/app/__tests__/council-store.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useCouncilStore } from "@/app/store/council-store";
|
||||
|
||||
describe("CouncilStore", () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state between tests
|
||||
useCouncilStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
councilName: "Mein Rat",
|
||||
activeRun: null,
|
||||
activeNodeId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should have default state", () => {
|
||||
const state = useCouncilStore.getState();
|
||||
expect(state.nodes).toEqual([]);
|
||||
expect(state.edges).toEqual([]);
|
||||
expect(state.selectedNodeId).toBeNull();
|
||||
expect(state.selectedEdgeId).toBeNull();
|
||||
expect(state.councilName).toBe("Mein Rat");
|
||||
});
|
||||
|
||||
it("should add an agent node", () => {
|
||||
const { addAgentNode } = useCouncilStore.getState();
|
||||
addAgentNode({ x: 100, y: 200 });
|
||||
|
||||
const { nodes } = useCouncilStore.getState();
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].position).toEqual({ x: 100, y: 200 });
|
||||
expect(nodes[0].type).toBe("agentNode");
|
||||
expect(nodes[0].data.label).toBe("Agent 1");
|
||||
expect(nodes[0].data.model).toBe("claude-3-5-sonnet");
|
||||
expect(nodes[0].data.tools).toEqual({ webSearch: false, pdfReader: false });
|
||||
});
|
||||
|
||||
it("should update node data", () => {
|
||||
const { addAgentNode } = useCouncilStore.getState();
|
||||
addAgentNode({ x: 0, y: 0 });
|
||||
|
||||
const { nodes, updateNodeData } = useCouncilStore.getState();
|
||||
const nodeId = nodes[0].id;
|
||||
|
||||
updateNodeData(nodeId, { label: "Master Agent", model: "gpt-4o" });
|
||||
|
||||
const updated = useCouncilStore.getState().nodes[0];
|
||||
expect(updated.data.label).toBe("Master Agent");
|
||||
expect(updated.data.model).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("should select a node and deselect edge", () => {
|
||||
const { selectNode } = useCouncilStore.getState();
|
||||
selectNode("node-1");
|
||||
|
||||
const state = useCouncilStore.getState();
|
||||
expect(state.selectedNodeId).toBe("node-1");
|
||||
expect(state.selectedEdgeId).toBeNull();
|
||||
});
|
||||
|
||||
it("should select an edge and deselect node", () => {
|
||||
const { selectEdge, selectNode } = useCouncilStore.getState();
|
||||
selectNode("node-1");
|
||||
selectEdge("edge-1");
|
||||
|
||||
const state = useCouncilStore.getState();
|
||||
expect(state.selectedEdgeId).toBe("edge-1");
|
||||
expect(state.selectedNodeId).toBeNull();
|
||||
});
|
||||
|
||||
it("should update edge data to conditional", () => {
|
||||
useCouncilStore.setState({
|
||||
edges: [
|
||||
{
|
||||
id: "e1",
|
||||
source: "a",
|
||||
target: "b",
|
||||
type: "default",
|
||||
data: { type: "linear" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { updateEdgeData } = useCouncilStore.getState();
|
||||
updateEdgeData("e1", "conditional", "rework");
|
||||
|
||||
const { edges } = useCouncilStore.getState();
|
||||
expect(edges[0].type).toBe("conditionalEdge");
|
||||
expect(edges[0].data?.type).toBe("conditional");
|
||||
expect(edges[0].data?.condition).toBe("rework");
|
||||
expect(edges[0].animated).toBe(true);
|
||||
});
|
||||
|
||||
it("should update edge data back to linear", () => {
|
||||
useCouncilStore.setState({
|
||||
edges: [
|
||||
{
|
||||
id: "e1",
|
||||
source: "a",
|
||||
target: "b",
|
||||
type: "conditionalEdge",
|
||||
data: { type: "conditional", condition: "approve" },
|
||||
animated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { updateEdgeData } = useCouncilStore.getState();
|
||||
updateEdgeData("e1", "linear");
|
||||
|
||||
const { edges } = useCouncilStore.getState();
|
||||
expect(edges[0].type).toBe("default");
|
||||
expect(edges[0].data?.type).toBe("linear");
|
||||
expect(edges[0].animated).toBe(false);
|
||||
});
|
||||
|
||||
it("should mark a node as active by name", () => {
|
||||
useCouncilStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "n1",
|
||||
type: "agentNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: "Master Agent",
|
||||
systemPrompt: "",
|
||||
model: "claude-3-5-sonnet" as const,
|
||||
tools: { webSearch: false, pdfReader: false },
|
||||
isActive: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { markNodeActive } = useCouncilStore.getState();
|
||||
markNodeActive("Master Agent");
|
||||
|
||||
const { nodes, activeNodeId } = useCouncilStore.getState();
|
||||
expect(activeNodeId).toBe("n1");
|
||||
expect(nodes[0].data.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it("should clear active node", () => {
|
||||
useCouncilStore.setState({
|
||||
activeNodeId: "n1",
|
||||
nodes: [
|
||||
{
|
||||
id: "n1",
|
||||
type: "agentNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: "Test",
|
||||
systemPrompt: "",
|
||||
model: "claude-3-5-sonnet" as const,
|
||||
tools: { webSearch: false, pdfReader: false },
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { clearActiveNode } = useCouncilStore.getState();
|
||||
clearActiveNode();
|
||||
|
||||
const { nodes, activeNodeId } = useCouncilStore.getState();
|
||||
expect(activeNodeId).toBeNull();
|
||||
expect(nodes[0].data.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("should set council name", () => {
|
||||
const { setCouncilName } = useCouncilStore.getState();
|
||||
setCouncilName("Test Rat");
|
||||
|
||||
expect(useCouncilStore.getState().councilName).toBe("Test Rat");
|
||||
});
|
||||
});
|
||||
80
frontend/app/__tests__/types.test.ts
Normal file
80
frontend/app/__tests__/types.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
AgentNodeData,
|
||||
CouncilBlueprint,
|
||||
ExecutionMode,
|
||||
GodModeAction,
|
||||
GodModeState,
|
||||
RunStatus,
|
||||
WSEventType,
|
||||
WSMessage,
|
||||
} from "@/app/types/council";
|
||||
|
||||
describe("Council types", () => {
|
||||
it("should support all run statuses", () => {
|
||||
const statuses: RunStatus[] = ["pending", "running", "completed", "failed", "paused"];
|
||||
expect(statuses).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should support execution modes", () => {
|
||||
const modes: ExecutionMode[] = ["auto-pilot", "god-mode"];
|
||||
expect(modes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should support god mode actions", () => {
|
||||
const actions: GodModeAction[] = ["approve", "reject", "modify"];
|
||||
expect(actions).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should support all WS event types", () => {
|
||||
const events: WSEventType[] = [
|
||||
"connected",
|
||||
"node_active",
|
||||
"run_paused",
|
||||
"run_resumed",
|
||||
"run_complete",
|
||||
"run_failed",
|
||||
];
|
||||
expect(events).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("should enforce AgentNodeData structure", () => {
|
||||
const data: AgentNodeData = {
|
||||
label: "Test Agent",
|
||||
systemPrompt: "You are a test agent.",
|
||||
model: "claude-3-5-sonnet",
|
||||
tools: { webSearch: true, pdfReader: false },
|
||||
isActive: false,
|
||||
};
|
||||
expect(data.label).toBe("Test Agent");
|
||||
expect(data.tools.webSearch).toBe(true);
|
||||
});
|
||||
|
||||
it("should enforce GodModeState structure", () => {
|
||||
const state: GodModeState = {
|
||||
run_id: "test-run",
|
||||
paused: true,
|
||||
next_nodes: ["critic"],
|
||||
current_state: {
|
||||
current_draft: "Draft text",
|
||||
critic_score: 6.5,
|
||||
iteration_count: 2,
|
||||
},
|
||||
};
|
||||
expect(state.paused).toBe(true);
|
||||
expect(state.next_nodes).toContain("critic");
|
||||
expect(state.current_state.critic_score).toBe(6.5);
|
||||
});
|
||||
|
||||
it("should enforce WSMessage structure", () => {
|
||||
const msg: WSMessage = {
|
||||
event: "run_paused",
|
||||
run_id: "test",
|
||||
next_nodes: ["agent1"],
|
||||
current_draft: "Draft",
|
||||
critic_score: 7.0,
|
||||
};
|
||||
expect(msg.event).toBe("run_paused");
|
||||
expect(msg.next_nodes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
MiniMap,
|
||||
BackgroundVariant,
|
||||
useReactFlow,
|
||||
Edge,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ export function ArchitectCanvas() {
|
|||
const onConnect = useCouncilStore((s) => s.onConnect);
|
||||
const addAgentNode = useCouncilStore((s) => s.addAgentNode);
|
||||
const selectNode = useCouncilStore((s) => s.selectNode);
|
||||
const selectEdge = useCouncilStore((s) => s.selectEdge);
|
||||
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
|
|
@ -53,7 +55,15 @@ export function ArchitectCanvas() {
|
|||
|
||||
const onPaneClick = useCallback(() => {
|
||||
selectNode(null);
|
||||
}, [selectNode]);
|
||||
selectEdge(null);
|
||||
}, [selectNode, selectEdge]);
|
||||
|
||||
const onEdgeClick = useCallback(
|
||||
(_event: React.MouseEvent, edge: Edge) => {
|
||||
selectEdge(edge.id);
|
||||
},
|
||||
[selectEdge]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full">
|
||||
|
|
@ -66,6 +76,7 @@ export function ArchitectCanvas() {
|
|||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodeTypes={NODE_TYPES}
|
||||
edgeTypes={EDGE_TYPES}
|
||||
fitView
|
||||
|
|
|
|||
146
frontend/app/components/panels/EdgeSettingsPanel.tsx
Normal file
146
frontend/app/components/panels/EdgeSettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, ArrowRight } from "lucide-react";
|
||||
import { EdgeType } from "@/app/types/council";
|
||||
import { useCouncilStore } from "@/app/store/council-store";
|
||||
|
||||
// Right-side panel shown when a canvas edge is selected
|
||||
export function EdgeSettingsPanel() {
|
||||
const selectedEdgeId = useCouncilStore((s) => s.selectedEdgeId);
|
||||
const edges = useCouncilStore((s) => s.edges);
|
||||
const nodes = useCouncilStore((s) => s.nodes);
|
||||
const updateEdgeData = useCouncilStore((s) => s.updateEdgeData);
|
||||
const selectEdge = useCouncilStore((s) => s.selectEdge);
|
||||
|
||||
const edge = edges.find((e) => e.id === selectedEdgeId);
|
||||
|
||||
const [edgeType, setEdgeType] = useState<EdgeType>("linear");
|
||||
const [condition, setCondition] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (edge) {
|
||||
setEdgeType((edge.data?.type as EdgeType) ?? "linear");
|
||||
setCondition((edge.data?.condition as string) ?? "");
|
||||
}
|
||||
}, [selectedEdgeId, edge]);
|
||||
|
||||
if (!selectedEdgeId || !edge) return null;
|
||||
|
||||
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||
const targetNode = nodes.find((n) => n.id === edge.target);
|
||||
|
||||
const handleTypeChange = (newType: EdgeType) => {
|
||||
setEdgeType(newType);
|
||||
updateEdgeData(selectedEdgeId, newType, newType === "conditional" ? condition : undefined);
|
||||
};
|
||||
|
||||
const handleConditionChange = (value: string) => {
|
||||
setCondition(value);
|
||||
updateEdgeData(selectedEdgeId, edgeType, value);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="w-72 flex-shrink-0 bg-white border-l border-slate-200 p-4 flex flex-col gap-4 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRight size={16} className="text-indigo-600" />
|
||||
<h2 className="font-semibold text-slate-800 text-sm flex-1">
|
||||
Kanten-Einstellungen
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => selectEdge(null)}
|
||||
className="text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection info */}
|
||||
<div className="rounded-lg bg-slate-50 p-3 text-xs text-slate-600 space-y-1">
|
||||
<p>
|
||||
<strong>Von:</strong>{" "}
|
||||
{sourceNode ? (sourceNode.data as { label: string }).label : edge.source}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Nach:</strong>{" "}
|
||||
{targetNode ? (targetNode.data as { label: string }).label : edge.target}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Edge type */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-slate-500">Typ</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleTypeChange("linear")}
|
||||
className={[
|
||||
"flex-1 text-sm px-3 py-2 rounded-lg border transition-colors",
|
||||
edgeType === "linear"
|
||||
? "bg-slate-100 border-slate-400 text-slate-800 font-medium"
|
||||
: "bg-white border-slate-200 text-slate-500 hover:border-slate-300",
|
||||
].join(" ")}
|
||||
>
|
||||
Linear
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTypeChange("conditional")}
|
||||
className={[
|
||||
"flex-1 text-sm px-3 py-2 rounded-lg border transition-colors",
|
||||
edgeType === "conditional"
|
||||
? "bg-indigo-50 border-indigo-400 text-indigo-800 font-medium"
|
||||
: "bg-white border-slate-200 text-slate-500 hover:border-slate-300",
|
||||
].join(" ")}
|
||||
>
|
||||
Bedingt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Condition value (only for conditional edges) */}
|
||||
{edgeType === "conditional" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-slate-500">
|
||||
Bedingung (Routing-Wert)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={condition}
|
||||
onChange={(e) => handleConditionChange(e.target.value)}
|
||||
placeholder='z.B. "rework" oder "approve"'
|
||||
className="rounded-lg border border-slate-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
/>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Dieser Wert wird mit <code className="bg-slate-100 px-1 rounded">route_decision</code> im
|
||||
State verglichen, um den Pfad zu bestimmen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preset conditions */}
|
||||
{edgeType === "conditional" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-slate-500">
|
||||
Schnellauswahl
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{["approve", "rework", "done", "escalate"].map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => handleConditionChange(preset)}
|
||||
className={[
|
||||
"text-xs px-2 py-1 rounded-full border transition-colors",
|
||||
condition === preset
|
||||
? "bg-indigo-600 text-white border-indigo-600"
|
||||
: "bg-white text-slate-600 border-slate-200 hover:border-indigo-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
113
frontend/app/components/panels/GodModePanel.tsx
Normal file
113
frontend/app/components/panels/GodModePanel.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, X, Pencil, Shield } from "lucide-react";
|
||||
import { GodModeAction } from "@/app/types/council";
|
||||
import { PauseInfo } from "@/app/hooks/useCouncilWebSocket";
|
||||
|
||||
interface Props {
|
||||
pauseInfo: PauseInfo;
|
||||
onAction: (action: GodModeAction, modifiedDraft?: string) => void;
|
||||
isResuming: boolean;
|
||||
}
|
||||
|
||||
// God Mode approval panel — shown when the graph pauses at a node
|
||||
export function GodModePanel({ pauseInfo, onAction, isResuming }: Props) {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editedDraft, setEditedDraft] = useState(pauseInfo.current_draft);
|
||||
|
||||
const handleModify = () => {
|
||||
if (editMode) {
|
||||
onAction("modify", editedDraft);
|
||||
setEditMode(false);
|
||||
} else {
|
||||
setEditedDraft(pauseInfo.current_draft);
|
||||
setEditMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border-2 border-amber-300 bg-amber-50 p-4 space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={18} className="text-amber-600" />
|
||||
<h3 className="font-semibold text-sm text-amber-800">
|
||||
God Mode — Freigabe erforderlich
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Info about which node is next */}
|
||||
<div className="text-xs text-amber-700 space-y-1">
|
||||
<p>
|
||||
<strong>Nächster Agent:</strong>{" "}
|
||||
{pauseInfo.next_nodes.join(", ") || "—"}
|
||||
</p>
|
||||
{pauseInfo.iteration_count != null && (
|
||||
<p>
|
||||
<strong>Iteration:</strong> {pauseInfo.iteration_count}
|
||||
</p>
|
||||
)}
|
||||
{pauseInfo.critic_score != null && (
|
||||
<p>
|
||||
<strong>Bewertung:</strong> {pauseInfo.critic_score}/10
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current draft preview / editor */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-amber-700">
|
||||
Aktueller Entwurf
|
||||
</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editedDraft}
|
||||
onChange={(e) => setEditedDraft(e.target.value)}
|
||||
rows={8}
|
||||
className="rounded-lg border border-amber-300 bg-white px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg bg-white border border-amber-200 p-3 text-sm text-slate-700 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
|
||||
{pauseInfo.current_draft || (
|
||||
<span className="italic text-slate-400">Kein Entwurf vorhanden</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onAction("approve")}
|
||||
disabled={isResuming}
|
||||
className="flex items-center gap-1.5 text-sm text-white bg-green-600 px-3 py-1.5 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Check size={14} />
|
||||
Genehmigen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleModify}
|
||||
disabled={isResuming}
|
||||
className="flex items-center gap-1.5 text-sm text-white bg-blue-600 px-3 py-1.5 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{editMode ? "Änderung senden" : "Ändern"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAction("reject")}
|
||||
disabled={isResuming}
|
||||
className="flex items-center gap-1.5 text-sm text-white bg-red-500 px-3 py-1.5 rounded-lg hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X size={14} />
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isResuming && (
|
||||
<p className="text-xs text-amber-600 animate-pulse">
|
||||
Wird fortgesetzt…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,14 +5,29 @@ import { WSMessage } from "@/app/types/council";
|
|||
import { wsUrl } from "@/app/utils/api-client";
|
||||
import { useCouncilStore } from "@/app/store/council-store";
|
||||
|
||||
export interface PauseInfo {
|
||||
next_nodes: string[];
|
||||
current_draft: string;
|
||||
critic_score?: number;
|
||||
iteration_count?: number;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
run_id: string | null;
|
||||
onComplete?: (result: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
onPaused?: (info: PauseInfo) => void;
|
||||
onResumed?: () => void;
|
||||
}
|
||||
|
||||
// WebSocket hook for live agent status updates during a council run
|
||||
export function useCouncilWebSocket({ run_id, onComplete, onError }: Options) {
|
||||
export function useCouncilWebSocket({
|
||||
run_id,
|
||||
onComplete,
|
||||
onError,
|
||||
onPaused,
|
||||
onResumed,
|
||||
}: Options) {
|
||||
const ws = useRef<WebSocket | null>(null);
|
||||
const markNodeActive = useCouncilStore((s) => s.markNodeActive);
|
||||
const clearActiveNode = useCouncilStore((s) => s.clearActiveNode);
|
||||
|
|
@ -40,20 +55,29 @@ export function useCouncilWebSocket({ run_id, onComplete, onError }: Options) {
|
|||
return;
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "node_enter":
|
||||
if (msg.node_name) markNodeActive(msg.node_name);
|
||||
switch (msg.event) {
|
||||
case "node_active":
|
||||
if (msg.node) markNodeActive(msg.node);
|
||||
break;
|
||||
case "node_exit":
|
||||
case "run_paused":
|
||||
clearActiveNode();
|
||||
onPaused?.({
|
||||
next_nodes: msg.next_nodes ?? [],
|
||||
current_draft: msg.current_draft ?? "",
|
||||
critic_score: msg.critic_score,
|
||||
iteration_count: msg.iteration_count,
|
||||
});
|
||||
break;
|
||||
case "run_resumed":
|
||||
onResumed?.();
|
||||
break;
|
||||
case "run_complete":
|
||||
clearActiveNode();
|
||||
setActiveRun(null);
|
||||
if (msg.result) onComplete?.(msg.result);
|
||||
if (msg.final_draft) onComplete?.(msg.final_draft);
|
||||
disconnect();
|
||||
break;
|
||||
case "run_error":
|
||||
case "run_failed":
|
||||
clearActiveNode();
|
||||
setActiveRun(null);
|
||||
if (msg.error) onError?.(msg.error);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { Play, Square, Upload } from "lucide-react";
|
||||
import { Play, Square, Upload, Shield, Zap } from "lucide-react";
|
||||
import { ArchitectCanvas } from "@/app/components/ArchitectCanvas";
|
||||
import { useCouncilWebSocket } from "@/app/hooks/useCouncilWebSocket";
|
||||
import { GodModePanel } from "@/app/components/panels/GodModePanel";
|
||||
import { useCouncilWebSocket, PauseInfo } from "@/app/hooks/useCouncilWebSocket";
|
||||
import { useCouncilStore } from "@/app/store/council-store";
|
||||
import { runApi } from "@/app/utils/api-client";
|
||||
import { runApi, pdfApi } from "@/app/utils/api-client";
|
||||
import { ExecutionMode, GodModeAction } from "@/app/types/council";
|
||||
|
||||
export default function KonferenzzimmerPage() {
|
||||
const [topic, setTopic] = useState("");
|
||||
|
|
@ -14,6 +16,10 @@ export default function KonferenzzimmerPage() {
|
|||
const [result, setResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [executionMode, setExecutionMode] = useState<ExecutionMode>("auto-pilot");
|
||||
const [pauseInfo, setPauseInfo] = useState<PauseInfo | null>(null);
|
||||
const [isResuming, setIsResuming] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setActiveRun = useCouncilStore((s) => s.setActiveRun);
|
||||
const clearActiveNode = useCouncilStore((s) => s.clearActiveNode);
|
||||
|
|
@ -22,24 +28,38 @@ export default function KonferenzzimmerPage() {
|
|||
setResult(res);
|
||||
setIsRunning(false);
|
||||
setRunId(null);
|
||||
setPauseInfo(null);
|
||||
}, []);
|
||||
|
||||
const onError = useCallback((err: string) => {
|
||||
setError(err);
|
||||
setIsRunning(false);
|
||||
setRunId(null);
|
||||
setPauseInfo(null);
|
||||
}, []);
|
||||
|
||||
useCouncilWebSocket({ run_id: runId, onComplete, onError });
|
||||
const onPaused = useCallback((info: PauseInfo) => {
|
||||
setPauseInfo(info);
|
||||
setIsResuming(false);
|
||||
}, []);
|
||||
|
||||
const onResumed = useCallback(() => {
|
||||
setPauseInfo(null);
|
||||
setIsResuming(false);
|
||||
}, []);
|
||||
|
||||
useCouncilWebSocket({ run_id: runId, onComplete, onError, onPaused, onResumed });
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!topic.trim()) return;
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setIsRunning(true);
|
||||
setPauseInfo(null);
|
||||
clearActiveNode();
|
||||
try {
|
||||
const run = await runApi.start(topic);
|
||||
const godMode = executionMode === "god-mode";
|
||||
const run = await runApi.start(topic, godMode);
|
||||
setActiveRun(run);
|
||||
setRunId(run.run_id);
|
||||
} catch (e) {
|
||||
|
|
@ -53,6 +73,44 @@ export default function KonferenzzimmerPage() {
|
|||
setIsRunning(false);
|
||||
clearActiveNode();
|
||||
setActiveRun(null);
|
||||
setPauseInfo(null);
|
||||
};
|
||||
|
||||
const handleGodModeAction = async (action: GodModeAction, modifiedDraft?: string) => {
|
||||
if (!runId) return;
|
||||
setIsResuming(true);
|
||||
|
||||
try {
|
||||
const modified_state = modifiedDraft ? { current_draft: modifiedDraft } : undefined;
|
||||
await runApi.approve(runId, action, modified_state);
|
||||
|
||||
if (action === "reject") {
|
||||
setError("Vom Benutzer im God Mode abgelehnt.");
|
||||
setIsRunning(false);
|
||||
setRunId(null);
|
||||
setPauseInfo(null);
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Fehler bei God Mode Aktion: " + (e as Error).message);
|
||||
setIsResuming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdfUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const res = await pdfApi.upload(file);
|
||||
setTopic((prev) =>
|
||||
prev
|
||||
? `${prev}\n\n[PDF hochgeladen: ${res.filename} — ${res.chunks_ingested} Abschnitte]`
|
||||
: `[PDF hochgeladen: ${res.filename} — ${res.chunks_ingested} Abschnitte]`
|
||||
);
|
||||
} catch (e) {
|
||||
setError("PDF-Upload fehlgeschlagen: " + (e as Error).message);
|
||||
}
|
||||
// Reset the input
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -66,10 +124,51 @@ export default function KonferenzzimmerPage() {
|
|||
rows={1}
|
||||
className="flex-1 rounded-lg border border-slate-200 px-3 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-indigo-300"
|
||||
/>
|
||||
<button className="flex items-center gap-1.5 text-sm text-slate-600 border border-slate-200 px-3 py-1.5 rounded-lg hover:bg-slate-50 transition-colors">
|
||||
|
||||
{/* PDF upload */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handlePdfUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-1.5 text-sm text-slate-600 border border-slate-200 px-3 py-1.5 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<Upload size={14} />
|
||||
PDF
|
||||
</button>
|
||||
|
||||
{/* Execution mode toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
setExecutionMode((m) => (m === "auto-pilot" ? "god-mode" : "auto-pilot"))
|
||||
}
|
||||
disabled={isRunning}
|
||||
className={[
|
||||
"flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition-colors border",
|
||||
executionMode === "god-mode"
|
||||
? "bg-amber-50 text-amber-700 border-amber-300 hover:bg-amber-100"
|
||||
: "bg-slate-50 text-slate-600 border-slate-200 hover:bg-slate-100",
|
||||
isRunning ? "opacity-50 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
title={
|
||||
executionMode === "god-mode"
|
||||
? "God Mode: Pause vor jedem Agenten"
|
||||
: "Auto-Pilot: Automatischer Durchlauf"
|
||||
}
|
||||
>
|
||||
{executionMode === "god-mode" ? (
|
||||
<Shield size={14} />
|
||||
) : (
|
||||
<Zap size={14} />
|
||||
)}
|
||||
{executionMode === "god-mode" ? "God Mode" : "Auto-Pilot"}
|
||||
</button>
|
||||
|
||||
{/* Start / Stop */}
|
||||
{!isRunning ? (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
|
|
@ -95,11 +194,16 @@ export default function KonferenzzimmerPage() {
|
|||
<ReactFlowProvider>
|
||||
<div className="flex-1 h-full relative">
|
||||
<ArchitectCanvas />
|
||||
{isRunning && (
|
||||
{isRunning && !pauseInfo && (
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 bg-indigo-600 text-white text-xs px-4 py-1.5 rounded-full shadow-lg animate-pulse pointer-events-none">
|
||||
Rat läuft…
|
||||
</div>
|
||||
)}
|
||||
{pauseInfo && (
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 bg-amber-500 text-white text-xs px-4 py-1.5 rounded-full shadow-lg pointer-events-none">
|
||||
Pausiert — Freigabe erforderlich
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ReactFlowProvider>
|
||||
|
||||
|
|
@ -108,7 +212,16 @@ export default function KonferenzzimmerPage() {
|
|||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<h2 className="text-sm font-semibold text-slate-700">Ergebnis</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* God Mode approval panel */}
|
||||
{pauseInfo && (
|
||||
<GodModePanel
|
||||
pauseInfo={pauseInfo}
|
||||
onAction={handleGodModeAction}
|
||||
isResuming={isResuming}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
||||
{error}
|
||||
|
|
@ -124,7 +237,7 @@ export default function KonferenzzimmerPage() {
|
|||
Noch kein Ergebnis. Starte den Rat mit einem Thema.
|
||||
</p>
|
||||
)}
|
||||
{isRunning && !result && (
|
||||
{isRunning && !result && !pauseInfo && (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Save, Download } from "lucide-react";
|
|||
import { ArchitectCanvas } from "@/app/components/ArchitectCanvas";
|
||||
import { NodeSidebar } from "@/app/components/panels/NodeSidebar";
|
||||
import { NodeSettingsPanel } from "@/app/components/panels/NodeSettingsPanel";
|
||||
import { EdgeSettingsPanel } from "@/app/components/panels/EdgeSettingsPanel";
|
||||
import { useCouncilStore } from "@/app/store/council-store";
|
||||
import { parseGraphToBlueprint } from "@/app/utils/blueprint-parser";
|
||||
import { councilApi } from "@/app/utils/api-client";
|
||||
|
|
@ -14,6 +15,8 @@ export default function RatArchitektPage() {
|
|||
const edges = useCouncilStore((s) => s.edges);
|
||||
const councilName = useCouncilStore((s) => s.councilName);
|
||||
const setCouncilName = useCouncilStore((s) => s.setCouncilName);
|
||||
const selectedNodeId = useCouncilStore((s) => s.selectedNodeId);
|
||||
const selectedEdgeId = useCouncilStore((s) => s.selectedEdgeId);
|
||||
|
||||
const handleSave = async () => {
|
||||
const blueprint = parseGraphToBlueprint(nodes, edges, councilName);
|
||||
|
|
@ -73,7 +76,8 @@ export default function RatArchitektPage() {
|
|||
<ReactFlowProvider>
|
||||
<NodeSidebar />
|
||||
<ArchitectCanvas />
|
||||
<NodeSettingsPanel />
|
||||
{selectedNodeId && <NodeSettingsPanel />}
|
||||
{selectedEdgeId && <EdgeSettingsPanel />}
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Zustand store for canvas state and council run state
|
||||
import { create } from "zustand";
|
||||
import { Node, Edge, addEdge, applyNodeChanges, applyEdgeChanges, NodeChange, EdgeChange, Connection } from "@xyflow/react";
|
||||
import { AgentNodeData, CouncilRun, LLMModel } from "@/app/types/council";
|
||||
import { AgentNodeData, CouncilRun, EdgeType } from "@/app/types/council";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
interface CouncilStore {
|
||||
|
|
@ -9,6 +9,7 @@ interface CouncilStore {
|
|||
nodes: Node<AgentNodeData>[];
|
||||
edges: Edge[];
|
||||
selectedNodeId: string | null;
|
||||
selectedEdgeId: string | null;
|
||||
councilName: string;
|
||||
|
||||
// Execution
|
||||
|
|
@ -22,6 +23,8 @@ interface CouncilStore {
|
|||
addAgentNode: (position: { x: number; y: number }) => void;
|
||||
updateNodeData: (nodeId: string, data: Partial<AgentNodeData>) => void;
|
||||
selectNode: (nodeId: string | null) => void;
|
||||
selectEdge: (edgeId: string | null) => void;
|
||||
updateEdgeData: (edgeId: string, type: EdgeType, condition?: string) => void;
|
||||
setCouncilName: (name: string) => void;
|
||||
setNodes: (nodes: Node<AgentNodeData>[]) => void;
|
||||
setEdges: (edges: Edge[]) => void;
|
||||
|
|
@ -47,6 +50,7 @@ export const useCouncilStore = create<CouncilStore>((set, get) => ({
|
|||
nodes: [],
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
councilName: "Mein Rat",
|
||||
activeRun: null,
|
||||
activeNodeId: null,
|
||||
|
|
@ -88,7 +92,24 @@ export const useCouncilStore = create<CouncilStore>((set, get) => ({
|
|||
),
|
||||
})),
|
||||
|
||||
selectNode: (nodeId) => set({ selectedNodeId: nodeId }),
|
||||
selectNode: (nodeId) => set({ selectedNodeId: nodeId, selectedEdgeId: null }),
|
||||
|
||||
selectEdge: (edgeId) => set({ selectedEdgeId: edgeId, selectedNodeId: null }),
|
||||
|
||||
updateEdgeData: (edgeId, type, condition) =>
|
||||
set((state) => ({
|
||||
edges: state.edges.map((e) =>
|
||||
e.id === edgeId
|
||||
? {
|
||||
...e,
|
||||
type: type === "conditional" ? "conditionalEdge" : "default",
|
||||
data: { ...e.data, type, condition: condition ?? "" },
|
||||
label: type === "conditional" ? (condition || "?") : undefined,
|
||||
animated: type === "conditional",
|
||||
}
|
||||
: e
|
||||
),
|
||||
})),
|
||||
|
||||
setCouncilName: (name) => set({ councilName: name }),
|
||||
|
||||
|
|
@ -122,4 +143,3 @@ export const useCouncilStore = create<CouncilStore>((set, get) => ({
|
|||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ export interface CouncilBlueprint {
|
|||
}
|
||||
|
||||
// Council run (execution)
|
||||
export type RunStatus = "pending" | "running" | "completed" | "failed";
|
||||
export type RunStatus = "pending" | "running" | "completed" | "failed" | "paused";
|
||||
|
||||
export type ExecutionMode = "auto-pilot" | "god-mode";
|
||||
|
||||
export interface CouncilRun {
|
||||
run_id: string;
|
||||
|
|
@ -61,13 +63,43 @@ export interface CouncilRun {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
// God Mode state from the backend
|
||||
export interface GodModeState {
|
||||
run_id: string;
|
||||
paused: boolean;
|
||||
next_nodes: string[];
|
||||
current_state: {
|
||||
current_draft?: string;
|
||||
critic_score?: number;
|
||||
iteration_count?: number;
|
||||
feedback_history?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export type GodModeAction = "approve" | "reject" | "modify";
|
||||
|
||||
// WebSocket messages from backend
|
||||
export type WSMessageType = "node_enter" | "node_exit" | "run_complete" | "run_error";
|
||||
export type WSEventType =
|
||||
| "connected"
|
||||
| "node_active"
|
||||
| "run_paused"
|
||||
| "run_resumed"
|
||||
| "run_complete"
|
||||
| "run_failed";
|
||||
|
||||
export interface WSMessage {
|
||||
type: WSMessageType;
|
||||
node_id?: string;
|
||||
node_name?: string;
|
||||
result?: string;
|
||||
event: WSEventType;
|
||||
run_id: string;
|
||||
// node_active
|
||||
node?: string;
|
||||
iteration?: number;
|
||||
// run_paused
|
||||
next_nodes?: string[];
|
||||
current_draft?: string;
|
||||
critic_score?: number;
|
||||
iteration_count?: number;
|
||||
// run_complete
|
||||
final_draft?: string;
|
||||
// run_failed
|
||||
error?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// API client for the FastAPI backend
|
||||
import { CouncilBlueprint, CouncilRun } from "@/app/types/council";
|
||||
import { CouncilBlueprint, CouncilRun, GodModeAction, GodModeState } from "@/app/types/council";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
|
||||
|
||||
|
|
@ -39,14 +39,48 @@ export const councilApi = {
|
|||
|
||||
// Council run (execution)
|
||||
export const runApi = {
|
||||
start: (input_topic: string) =>
|
||||
request<CouncilRun>("/api/run", {
|
||||
start: (input_topic: string, god_mode: boolean = false) =>
|
||||
request<CouncilRun>("/api/councils/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ input_topic }),
|
||||
body: JSON.stringify({ input_topic, god_mode }),
|
||||
}),
|
||||
|
||||
startFromBlueprint: (blueprintId: string, input_topic: string, god_mode: boolean = false) =>
|
||||
request<CouncilRun>(`/api/councils/${blueprintId}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ input_topic, god_mode }),
|
||||
}),
|
||||
|
||||
status: (run_id: string) =>
|
||||
request<CouncilRun>(`/api/run/${run_id}`),
|
||||
request<CouncilRun>(`/api/councils/run/${run_id}`),
|
||||
|
||||
// God Mode: approve/reject/modify a paused run
|
||||
approve: (run_id: string, action: GodModeAction, modified_state?: Record<string, unknown>) =>
|
||||
request<CouncilRun>(`/api/councils/run/${run_id}/approve`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action, modified_state }),
|
||||
}),
|
||||
|
||||
// God Mode: get the paused state
|
||||
getState: (run_id: string) =>
|
||||
request<GodModeState>(`/api/councils/run/${run_id}/state`),
|
||||
};
|
||||
|
||||
// PDF upload
|
||||
export const pdfApi = {
|
||||
upload: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await fetch(`${BASE_URL}/api/councils/upload-pdf`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Upload error ${res.status}: ${text}`);
|
||||
}
|
||||
return res.json() as Promise<{ filename: string; chunks_ingested: number; message: string }>;
|
||||
},
|
||||
};
|
||||
|
||||
// WebSocket URL helper
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue