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:
Claude 2026-02-21 10:53:12 +00:00
parent c6d0c4a636
commit 001649a364
No known key found for this signature in database
31 changed files with 2502 additions and 81 deletions

View file

@ -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

View 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>
);
}

View 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>
);
}