Add web GUI: FastAPI API + Vue 3 frontend with dark theme
API (web/api.py):
GET /api/projects, /api/projects/{id}, /api/tasks/{id}
GET /api/decisions?project=X, /api/cost?days=7, /api/support/tickets
POST /api/projects, /api/tasks, /api/decisions, /api/bootstrap
CORS for localhost:5173, all queries via models.py
Frontend (web/frontend/):
Vue 3 + TypeScript + Vite + Tailwind CSS v3
Dashboard: project cards with task counters, cost, status badges
ProjectView: tabs for Tasks/Decisions/Modules with filters
Modals: Add Project, Add Task, Add Decision, Bootstrap
Dark theme, monospace font, minimal clean design
Startup:
API: cd web && uvicorn api:app --reload --port 8420
Web: cd web/frontend && npm install && npm run dev
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b95db7c7d6
commit
86e5b8febf
21 changed files with 3386 additions and 1 deletions
248
web/api.py
Normal file
248
web/api.py
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"""
|
||||
Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models.
|
||||
Run: uvicorn web.api:app --reload --port 8420
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure project root on sys.path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.db import init_db
|
||||
from core import models
|
||||
from agents.bootstrap import (
|
||||
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
|
||||
find_vault_root, scan_obsidian, save_to_db,
|
||||
)
|
||||
|
||||
DB_PATH = Path.home() / ".kin" / "kin.db"
|
||||
|
||||
app = FastAPI(title="Kin API", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def get_conn():
|
||||
return init_db(DB_PATH)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/projects")
|
||||
def list_projects(status: str | None = None):
|
||||
conn = get_conn()
|
||||
summary = models.get_project_summary(conn)
|
||||
if status:
|
||||
summary = [s for s in summary if s["status"] == status]
|
||||
conn.close()
|
||||
return summary
|
||||
|
||||
|
||||
@app.get("/api/projects/{project_id}")
|
||||
def get_project(project_id: str):
|
||||
conn = get_conn()
|
||||
p = models.get_project(conn, project_id)
|
||||
if not p:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Project '{project_id}' not found")
|
||||
tasks = models.list_tasks(conn, project_id=project_id)
|
||||
mods = models.get_modules(conn, project_id)
|
||||
decisions = models.get_decisions(conn, project_id)
|
||||
conn.close()
|
||||
return {**p, "tasks": tasks, "modules": mods, "decisions": decisions}
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
path: str
|
||||
tech_stack: list[str] | None = None
|
||||
status: str = "active"
|
||||
priority: int = 5
|
||||
|
||||
|
||||
@app.post("/api/projects")
|
||||
def create_project(body: ProjectCreate):
|
||||
conn = get_conn()
|
||||
if models.get_project(conn, body.id):
|
||||
conn.close()
|
||||
raise HTTPException(409, f"Project '{body.id}' already exists")
|
||||
p = models.create_project(
|
||||
conn, body.id, body.name, body.path,
|
||||
tech_stack=body.tech_stack, status=body.status, priority=body.priority,
|
||||
)
|
||||
conn.close()
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/tasks/{task_id}")
|
||||
def get_task(task_id: str):
|
||||
conn = get_conn()
|
||||
t = models.get_task(conn, task_id)
|
||||
conn.close()
|
||||
if not t:
|
||||
raise HTTPException(404, f"Task '{task_id}' not found")
|
||||
return t
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
project_id: str
|
||||
title: str
|
||||
priority: int = 5
|
||||
route_type: str | None = None
|
||||
|
||||
|
||||
@app.post("/api/tasks")
|
||||
def create_task(body: TaskCreate):
|
||||
conn = get_conn()
|
||||
p = models.get_project(conn, body.project_id)
|
||||
if not p:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Project '{body.project_id}' not found")
|
||||
# Auto-generate task ID
|
||||
existing = models.list_tasks(conn, project_id=body.project_id)
|
||||
prefix = body.project_id.upper()
|
||||
max_num = 0
|
||||
for t in existing:
|
||||
if t["id"].startswith(prefix + "-"):
|
||||
try:
|
||||
num = int(t["id"].split("-", 1)[1])
|
||||
max_num = max(max_num, num)
|
||||
except ValueError:
|
||||
pass
|
||||
task_id = f"{prefix}-{max_num + 1:03d}"
|
||||
brief = {"route_type": body.route_type} if body.route_type else None
|
||||
t = models.create_task(conn, task_id, body.project_id, body.title,
|
||||
priority=body.priority, brief=brief)
|
||||
conn.close()
|
||||
return t
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decisions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/decisions")
|
||||
def list_decisions(
|
||||
project: str = Query(...),
|
||||
category: str | None = None,
|
||||
tag: list[str] | None = Query(None),
|
||||
type: list[str] | None = Query(None),
|
||||
):
|
||||
conn = get_conn()
|
||||
decisions = models.get_decisions(
|
||||
conn, project, category=category, tags=tag, types=type,
|
||||
)
|
||||
conn.close()
|
||||
return decisions
|
||||
|
||||
|
||||
class DecisionCreate(BaseModel):
|
||||
project_id: str
|
||||
type: str
|
||||
title: str
|
||||
description: str
|
||||
category: str | None = None
|
||||
tags: list[str] | None = None
|
||||
task_id: str | None = None
|
||||
|
||||
|
||||
@app.post("/api/decisions")
|
||||
def create_decision(body: DecisionCreate):
|
||||
conn = get_conn()
|
||||
p = models.get_project(conn, body.project_id)
|
||||
if not p:
|
||||
conn.close()
|
||||
raise HTTPException(404, f"Project '{body.project_id}' not found")
|
||||
d = models.add_decision(
|
||||
conn, body.project_id, body.type, body.title, body.description,
|
||||
category=body.category, tags=body.tags, task_id=body.task_id,
|
||||
)
|
||||
conn.close()
|
||||
return d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/cost")
|
||||
def cost_summary(days: int = 7):
|
||||
conn = get_conn()
|
||||
costs = models.get_cost_summary(conn, days=days)
|
||||
conn.close()
|
||||
return costs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/support/tickets")
|
||||
def list_tickets(project: str | None = None, status: str | None = None):
|
||||
conn = get_conn()
|
||||
tickets = models.list_tickets(conn, project_id=project, status=status)
|
||||
conn.close()
|
||||
return tickets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bootstrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BootstrapRequest(BaseModel):
|
||||
path: str
|
||||
id: str
|
||||
name: str
|
||||
vault_path: str | None = None
|
||||
|
||||
|
||||
@app.post("/api/bootstrap")
|
||||
def bootstrap(body: BootstrapRequest):
|
||||
project_path = Path(body.path).expanduser().resolve()
|
||||
if not project_path.is_dir():
|
||||
raise HTTPException(400, f"Path '{body.path}' is not a directory")
|
||||
|
||||
conn = get_conn()
|
||||
if models.get_project(conn, body.id):
|
||||
conn.close()
|
||||
raise HTTPException(409, f"Project '{body.id}' already exists")
|
||||
|
||||
tech_stack = detect_tech_stack(project_path)
|
||||
modules = detect_modules(project_path)
|
||||
decisions = extract_decisions_from_claude_md(project_path, body.id, body.name)
|
||||
|
||||
obsidian = None
|
||||
vault_root = find_vault_root(Path(body.vault_path) if body.vault_path else None)
|
||||
if vault_root:
|
||||
dir_name = project_path.name
|
||||
obs = scan_obsidian(vault_root, body.id, body.name, dir_name)
|
||||
if obs["tasks"] or obs["decisions"]:
|
||||
obsidian = obs
|
||||
|
||||
save_to_db(conn, body.id, body.name, str(project_path),
|
||||
tech_stack, modules, decisions, obsidian)
|
||||
p = models.get_project(conn, body.id)
|
||||
conn.close()
|
||||
return {
|
||||
"project": p,
|
||||
"modules_count": len(modules),
|
||||
"decisions_count": len(decisions) + len((obsidian or {}).get("decisions", [])),
|
||||
"tasks_count": len((obsidian or {}).get("tasks", [])),
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue