Skip to content

Commit bd67611

Browse files
authored
feat: Introduce Kaizen Web UI and Management Dashboard (#77)
* feat(ui): formalize entity creation with React dropdowns and array mapped triggers * docs: add UI running instructions to README * feat(ui): initialize React frontend application * refactor(ui): fix CSS bugs, decompose EntityExplorer, add shared hooks & responsive design * fix: code quality improvements across backend and frontend * docs: add UI build instructions and run ruff * style: apply ruff format to frontend python files * fixing code review issues * chore: ignore package-lock.json in detect-secrets scan * chore: remove kaizen.schema.policy mypy omit and fallback since it now exists * fix(ui): confirm dialog signature, entity creation validation, dashboard key reconciliation, and backend requirements * fix(ui): provide fallback for dashboard error message * fix(api): enforce MAX_ENTITY_LIMIT for entity listing * fix(api): raise HTTPException instead of swallowing errors in listing routes
1 parent a54a976 commit bd67611

36 files changed

Lines changed: 7637 additions & 6 deletions

.github/workflows/check-code.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,20 @@ jobs:
8282
python-version: ${{ matrix.python-version }}
8383
- name: Check typing
8484
run: uv run mypy .
85+
86+
ui-tests:
87+
runs-on: ubuntu-latest
88+
steps:
89+
- uses: actions/checkout@v5
90+
- name: Set up Node.js
91+
uses: actions/setup-node@v4
92+
with:
93+
node-version: '20'
94+
cache: 'npm'
95+
cache-dependency-path: 'kaizen/frontend/ui/package-lock.json'
96+
- name: Install dependencies
97+
working-directory: kaizen/frontend/ui
98+
run: npm install
99+
- name: Run UI Unit Tests
100+
working-directory: kaizen/frontend/ui
101+
run: npm run test -- --run

.secrets.baseline

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"exclude": {
3-
"files": "^.secrets.baseline$",
3+
"files": "^.secrets.baseline$|package-lock\\.json$",
44
"lines": null
55
},
66
"generated_at": "2026-02-26T16:00:29Z",

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
recursive-include kaizen *.jinja2
2+
recursive-include kaizen/frontend/ui/dist *

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,40 @@ export OPENAI_API_KEY=sk-...
3838

3939
For LiteLLM proxy usage and model selection (including global fallback via `KAIZEN_MODEL_NAME`), see [CONFIGURATION.md](CONFIGURATION.md).
4040

41-
### Running the MCP Server
41+
### Running the MCP Server & UI
4242

43+
Kaizen provides both a standard MCP server and a full Web UI (Dashboard & Entity Explorer).
44+
45+
> [!IMPORTANT]
46+
> **Building from Source:** If you cloned this repository (rather than installing a pre-built package), you must build the UI before it can be served.
47+
> ```bash
48+
> cd kaizen/frontend/ui
49+
> npm ci && npm run build
50+
> cd ../../../
51+
> ```
52+
> See `kaizen/frontend/ui/README.md` for more frontend development details.
53+
54+
#### Starting Both Automatically
55+
The easiest way to start both the MCP Server (on standard input/output) and the HTTP UI backend is to run the module directly:
56+
```bash
57+
uv run python -m kaizen.frontend.mcp
58+
```
59+
This will start the UI server in the background on port `8000` and the MCP server in the foreground. You can then access the UI locally by opening your browser to:
60+
`http://127.0.0.1:8000/ui/`
61+
62+
#### Starting the UI Standalone
63+
If you only want to access the Web UI and API (without the MCP server stdio blocking the terminal), you can run the FastAPI application directly using `uvicorn`:
64+
```bash
65+
uv run uvicorn kaizen.frontend.mcp.mcp_server:app --host 127.0.0.1 --port 8000
66+
```
67+
Then navigate to `http://127.0.0.1:8000/ui/`.
68+
69+
#### Starting only the MCP Server
70+
If you're attaching Kaizen to an MCP client that requires a direct command (like Claude Desktop):
71+
```bash
72+
uv run fastmcp run kaizen/frontend/mcp/mcp_server.py --transport stdio
73+
```
74+
Or for SSE transport:
4375
```bash
4476
uv run fastmcp run kaizen/frontend/mcp/mcp_server.py --transport sse --port 8201
4577
```

kaizen/backend/milvus.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,22 @@ def get_namespace_details(self, namespace_id: str) -> Namespace:
232232
namespace = db_manager.get_namespace(namespace_id)
233233
if namespace is None:
234234
raise NamespaceNotFoundException(f"Namespace {namespace_id} not found")
235-
namespace.num_entities = self.milvus.get_collection_stats(namespace_id)["row_count"]
235+
try:
236+
namespace.num_entities = self.milvus.get_collection_stats(namespace_id)["row_count"]
237+
except Exception as e:
238+
logger.exception(f"Failed to get collection stats for namespace {namespace_id}: {e}")
239+
namespace.num_entities = 0
236240
return namespace
237241

238242
def search_namespaces(self, limit: int = 10) -> list[Namespace]:
239243
with SQLiteManager(self.sqlite_uri) as db_manager:
240244
namespaces = []
241245
for namespace in db_manager.search_namespaces(limit):
242-
namespace.num_entities = self.milvus.get_collection_stats(namespace.id)["row_count"]
246+
try:
247+
namespace.num_entities = self.milvus.get_collection_stats(namespace.id)["row_count"]
248+
except Exception as e:
249+
logger.exception(f"Failed to get collection stats for namespace {namespace.id}: {e}")
250+
namespace.num_entities = 0
243251
namespaces.append(namespace)
244252
return namespaces
245253

kaizen/frontend/api/__init__.py

Whitespace-only changes.

kaizen/frontend/api/routes.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
from typing import Any, List, Optional
2+
import logging
3+
from pydantic import BaseModel
4+
5+
from fastapi import APIRouter, Query
6+
7+
logger = logging.getLogger(__name__)
8+
9+
router = APIRouter()
10+
11+
MAX_ENTITY_LIMIT = 500
12+
13+
14+
class NamespaceCreateRequest(BaseModel):
15+
namespace_id: str
16+
17+
18+
class EntityCreateRequest(BaseModel):
19+
type: str
20+
content: str
21+
metadata: dict = {}
22+
23+
24+
@router.get("/dashboard")
25+
def get_dashboard() -> dict[str, Any]:
26+
from kaizen.frontend.mcp.mcp_server import get_client
27+
28+
client = get_client()
29+
30+
# 1. Backend health
31+
try:
32+
health = client.ready()
33+
except Exception as e:
34+
logger.error(f"Error checking health: {e}")
35+
health = False
36+
37+
# 2. Namespace count
38+
try:
39+
namespaces = client.all_namespaces(limit=1000)
40+
namespace_count = len(namespaces)
41+
except Exception as e:
42+
logger.error(f"Error fetching namespaces: {e}")
43+
namespaces = []
44+
namespace_count = 0
45+
46+
# 3. Entity counts and recent entities across namespaces
47+
# For MVP, we will aggregate from all available namespaces up to the limit
48+
total_entities = 0
49+
approximate_type_breakdown: dict[str, int] = {}
50+
recent_entities: list[dict[str, Any]] = []
51+
52+
for ns in namespaces:
53+
try:
54+
total_entities += ns.num_entities or 0
55+
# Fetch only a small sample per namespace for the dashboard
56+
ns_entities = client.get_all_entities(ns.id, limit=10)
57+
58+
for entity in ns_entities:
59+
etype = entity.type or "unknown"
60+
approximate_type_breakdown[etype] = approximate_type_breakdown.get(etype, 0) + 1
61+
62+
# Safely handle non-string content before slicing
63+
content = entity.content
64+
if isinstance(content, str):
65+
snippet = content[:100] + "..." if len(content) > 100 else content
66+
else:
67+
safe_str = str(content)
68+
snippet = safe_str[:100] + "..." if len(safe_str) > 100 else safe_str
69+
70+
recent_entities.append(
71+
{
72+
"id": entity.id,
73+
"type": entity.type,
74+
"content": snippet,
75+
"namespace": ns.id,
76+
"created_at": entity.created_at.isoformat() if hasattr(entity, "created_at") and entity.created_at else None,
77+
}
78+
)
79+
except Exception as e:
80+
logger.error(f"Error fetching entities for namespace {ns.id}: {e}")
81+
82+
# sort by created_at descending (assuming we have those or just use the end of list)
83+
# the client doesn't strictly order by date right now unless we extract or sort manually
84+
recent_entities.sort(key=lambda x: x.get("created_at") or "", reverse=True)
85+
recent_entities = recent_entities[:10] # top 10
86+
87+
return {
88+
"health": health,
89+
"namespace_count": namespace_count,
90+
"total_entities": total_entities,
91+
"approximate_type_breakdown": [{"type": k, "count": v} for k, v in approximate_type_breakdown.items()],
92+
"type_breakdown_is_approx": True,
93+
"recent_entities": recent_entities,
94+
}
95+
96+
97+
@router.get("/namespaces")
98+
def list_namespaces() -> List[dict[str, Any]]:
99+
from kaizen.frontend.mcp.mcp_server import get_client
100+
101+
client = get_client()
102+
try:
103+
namespaces = []
104+
for ns in client.all_namespaces(limit=1000):
105+
namespaces.append({"id": ns.id, "amount_of_entities": ns.num_entities or 0})
106+
return namespaces
107+
except Exception as e:
108+
from fastapi import HTTPException
109+
110+
logger.error(f"Error fetching namespaces: {e}")
111+
raise HTTPException(status_code=500, detail="Internal server error while fetching namespaces")
112+
113+
114+
@router.post("/namespaces")
115+
def add_namespace(req: NamespaceCreateRequest) -> dict[str, Any]:
116+
from kaizen.frontend.mcp.mcp_server import get_client
117+
118+
client = get_client()
119+
try:
120+
client.create_namespace(req.namespace_id)
121+
return {"success": True, "namespace_id": req.namespace_id}
122+
except Exception as e:
123+
from fastapi import HTTPException
124+
125+
logger.error(f"Error creating namespace: {e}")
126+
raise HTTPException(status_code=400, detail="Internal server error while creating namespace")
127+
128+
129+
@router.delete("/namespaces/{namespace_id}")
130+
def delete_namespace(namespace_id: str) -> dict[str, Any]:
131+
from kaizen.frontend.mcp.mcp_server import get_client
132+
133+
client = get_client()
134+
try:
135+
client.delete_namespace(namespace_id)
136+
return {"success": True}
137+
except Exception as e:
138+
from fastapi import HTTPException
139+
140+
logger.error(f"Error deleting namespace: {e}")
141+
raise HTTPException(status_code=400, detail="Internal server error while deleting namespace")
142+
143+
144+
@router.get("/namespaces/{namespace_id}/entities")
145+
def list_namespace_entities(
146+
namespace_id: str,
147+
type: Optional[str] = Query(None, description="Filter entities by type (e.g., guideline, task)"),
148+
limit: int = Query(100, description=f"Maximum number of entities to return (max {MAX_ENTITY_LIMIT})"),
149+
) -> List[dict[str, Any]]:
150+
from kaizen.frontend.mcp.mcp_server import get_client
151+
152+
client = get_client()
153+
try:
154+
# Sanitize limit
155+
limit = max(1, min(limit, MAX_ENTITY_LIMIT))
156+
157+
filters = {}
158+
if type:
159+
filters["type"] = type
160+
161+
entities = client.get_all_entities(namespace_id, filters=filters, limit=limit)
162+
163+
result = []
164+
for entity in entities:
165+
result.append(
166+
{
167+
"id": entity.id,
168+
"type": entity.type,
169+
"content": entity.content,
170+
"metadata": entity.metadata or {},
171+
"created_at": entity.created_at.isoformat() if hasattr(entity, "created_at") and entity.created_at else None,
172+
}
173+
)
174+
175+
# Sort by created_at descending
176+
result.sort(key=lambda x: str(x.get("created_at") or ""), reverse=True)
177+
return result
178+
except Exception as e:
179+
from fastapi import HTTPException
180+
181+
logger.error(f"Error fetching entities for namespace {namespace_id}: {e}")
182+
raise HTTPException(status_code=500, detail="Internal server error while fetching entities")
183+
184+
185+
@router.delete("/namespaces/{namespace_id}/entities/{entity_id}")
186+
def delete_namespace_entity(namespace_id: str, entity_id: str) -> dict[str, Any]:
187+
from kaizen.frontend.mcp.mcp_server import get_client
188+
189+
client = get_client()
190+
try:
191+
client.delete_entity_by_id(namespace_id, entity_id)
192+
return {"success": True}
193+
except Exception as e:
194+
from fastapi import HTTPException
195+
196+
logger.error(f"Error deleting entity {entity_id} from namespace {namespace_id}: {e}")
197+
raise HTTPException(status_code=400, detail="Internal server error while deleting entity")
198+
199+
200+
@router.post("/namespaces/{namespace_id}/entities")
201+
def create_namespace_entity(namespace_id: str, req: EntityCreateRequest) -> dict[str, Any]:
202+
from kaizen.frontend.mcp.mcp_server import get_client
203+
from kaizen.schema.core import Entity
204+
from fastapi import HTTPException
205+
206+
# 1. Normalize and validate inputs before branching
207+
entity_type = req.type.strip().lower()
208+
if not req.content or not req.content.strip():
209+
raise HTTPException(status_code=422, detail="Entity content must be non-empty.")
210+
211+
# 2. Enforce specific schema typing prior to insertion
212+
if entity_type == "guideline":
213+
from kaizen.schema.tips import Tip
214+
215+
try:
216+
# Tip expects content at the root, so we map req.content and unpack the metadata
217+
tip_meta = {k: v for k, v in req.metadata.items() if k != "content"}
218+
Tip(content=req.content, **tip_meta)
219+
except Exception as e:
220+
logger.error(f"Guideline validation failed: {e}")
221+
raise HTTPException(status_code=422, detail=f"Invalid guideline metadata schema: {e}")
222+
223+
elif entity_type == "policy":
224+
from kaizen.schema.policy import Policy, PolicyType
225+
226+
try:
227+
# The Policy model checks the full payload
228+
policy_meta = {k: v for k, v in req.metadata.items() if k != "content"}
229+
Policy(content=req.content, type=PolicyType(req.metadata["policy_type"]), **policy_meta)
230+
except Exception as e:
231+
logger.error(f"Policy validation failed: {e}")
232+
raise HTTPException(status_code=422, detail=f"Invalid policy metadata schema: {e}")
233+
234+
client = get_client()
235+
try:
236+
new_entity = Entity(type=entity_type, content=req.content, metadata=req.metadata)
237+
# Using enable_conflict_resolution=False for a direct insert
238+
updates = client.update_entities(namespace_id, [new_entity], enable_conflict_resolution=False)
239+
if not updates:
240+
raise Exception("Failed to insert entity. No updates returned.")
241+
return {"success": True, "id": updates[0].id}
242+
except Exception as e:
243+
logger.error(f"Error creating entity in namespace {namespace_id}: {e}")
244+
raise HTTPException(status_code=400, detail="Internal server error while creating entity")

kaizen/frontend/mcp/__main__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
import logging
22
import sys
3+
import threading
4+
import uvicorn
35

4-
from kaizen.frontend.mcp.mcp_server import mcp
6+
from kaizen.frontend.mcp.mcp_server import mcp, app
57

68
logger = logging.getLogger("kaizen-mcp")
79

810

11+
def run_api_server():
12+
"""Run the FastAPI server for UI and API in a background thread."""
13+
try:
14+
# We run with log_level="warning" to avoid cluttering stdio for MCP
15+
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="warning")
16+
except Exception as e:
17+
logging.error(f"Failed to start UI server: {e}")
18+
19+
920
def main():
1021
"""
1122
Main entry point for the server.
1223
"""
24+
# Start the HTTP API/UI server in a daemon thread so it dies when the parent dies
25+
api_thread = threading.Thread(target=run_api_server, daemon=True)
26+
api_thread.start()
27+
1328
try:
29+
# Start FastMCP using stdio (which blocks)
1430
mcp.run()
1531
except KeyboardInterrupt:
1632
logger.info("MCP server stopped by user (KeyboardInterrupt)")

0 commit comments

Comments
 (0)