Spaces:
Running
Running
matrix-builder-bot commited on
Commit ·
66d0c75
1
Parent(s): 4cea3e5
deploy: matrix-builder@ae902df6985a
Browse filesSource: https://github.com/agent-matrix/matrix-builder/commit/ae902df6985a7d7a550b94dc5634e76db0109a45
- Dockerfile +4 -1
- requirements.txt +7 -0
- services/api/app/api/blueprints.py +75 -1
- services/api/app/db/orm.py +24 -0
- services/api/app/schemas/blueprint.py +88 -0
- services/api/app/services/design_bundle_store.py +105 -0
- services/api/app/services/designer_client.py +196 -0
- services/api/migrations/versions/0006_design_bundles.py +49 -0
- start.sh +13 -0
- web/src/app/matrix-builder/MatrixBuilderClient.tsx +318 -4
- web/src/components/settings/AiConfigurationSection.tsx +32 -0
- web/src/lib/__tests__/blueprint-engine.test.ts +75 -0
- web/src/lib/__tests__/blueprint-persistence.test.ts +70 -0
- web/src/lib/__tests__/offline-workspace.test.ts +87 -0
- web/src/lib/ai-refine.ts +73 -0
- web/src/lib/ai-settings-store.ts +9 -0
- web/src/lib/blueprint-client.ts +116 -0
- web/src/lib/blueprint-engine.ts +336 -0
- web/src/lib/blueprint-persistence.ts +50 -0
- web/src/lib/blueprint-store.ts +106 -0
- web/src/lib/capabilities.ts +45 -0
- web/src/styles/matrix-builder.css +187 -0
- web/src/types/ai-settings.ts +11 -0
- web/src/types/blueprint-state.ts +84 -0
Dockerfile
CHANGED
|
@@ -30,7 +30,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PYTHONPATH=/app/services/api \
|
|
| 30 |
AGENT_GENERATOR_MODE=sdk MATRIX_DEFINITIONS_MODE=sdk \
|
| 31 |
# Public, absolute API base so the coder prompt's "Fetch the Matrix Bundle" link is a real,
|
| 32 |
# fetchable signed URL (build.matrixhub.io proxies /api/builder/* to this backend).
|
| 33 |
-
PUBLIC_API_BASE_URL=https://build.matrixhub.io/api/builder/api/v1
|
|
|
|
|
|
|
|
|
|
| 34 |
WORKDIR /app
|
| 35 |
RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv \
|
| 36 |
&& rm -rf /var/lib/apt/lists/* \
|
|
|
|
| 30 |
AGENT_GENERATOR_MODE=sdk MATRIX_DEFINITIONS_MODE=sdk \
|
| 31 |
# Public, absolute API base so the coder prompt's "Fetch the Matrix Bundle" link is a real,
|
| 32 |
# fetchable signed URL (build.matrixhub.io proxies /api/builder/* to this backend).
|
| 33 |
+
PUBLIC_API_BASE_URL=https://build.matrixhub.io/api/builder/api/v1 \
|
| 34 |
+
# Matrix Designer (the brain) runs as an internal service in this same container (started by
|
| 35 |
+
# start.sh). The control plane calls it here and falls back to deterministic generation if down.
|
| 36 |
+
MATRIX_DESIGNER_PORT=8077 MATRIX_DESIGNER_URL=http://127.0.0.1:8077
|
| 37 |
WORKDIR /app
|
| 38 |
RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-venv \
|
| 39 |
&& rm -rf /var/lib/apt/lists/* \
|
requirements.txt
CHANGED
|
@@ -36,3 +36,10 @@ httpx>=0.27
|
|
| 36 |
jsonschema>=4.22
|
| 37 |
ruff>=0.6
|
| 38 |
mypy>=1.11
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
jsonschema>=4.22
|
| 37 |
ruff>=0.6
|
| 38 |
mypy>=1.11
|
| 39 |
+
|
| 40 |
+
# --- Matrix Designer (the brain) — installed alongside on Hugging Face ---
|
| 41 |
+
# When this image is built (HF Space / Docker), Matrix Designer is installed and its
|
| 42 |
+
# internal HTTP service is started by start.sh; the control plane reaches it at
|
| 43 |
+
# MATRIX_DESIGNER_URL (default http://127.0.0.1:8077) and falls back to deterministic
|
| 44 |
+
# generation if it's unavailable.
|
| 45 |
+
matrix-designer[service] @ git+https://github.com/agent-matrix/matrix-designer.git
|
services/api/app/api/blueprints.py
CHANGED
|
@@ -1,13 +1,23 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from fastapi import APIRouter, Depends
|
| 4 |
|
|
|
|
| 5 |
from app.schemas.blueprint import (
|
| 6 |
BlueprintCandidateResponse,
|
|
|
|
|
|
|
|
|
|
| 7 |
BlueprintGenerationRequest,
|
| 8 |
BlueprintResult,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
)
|
| 10 |
from app.schemas.idea import IdeaRequest
|
|
|
|
|
|
|
| 11 |
from app.services.matrix_builder_service import MatrixBuilderService
|
| 12 |
from app.dependencies import get_matrix_builder_service
|
| 13 |
|
|
@@ -36,3 +46,67 @@ def generate_selected_blueprint(
|
|
| 36 |
service: MatrixBuilderService = Depends(get_matrix_builder_service),
|
| 37 |
) -> BlueprintResult:
|
| 38 |
return service.generate_blueprint(payload.idea_request, candidate_id=payload.candidate_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from fastapi import APIRouter, Depends, Query
|
| 4 |
|
| 5 |
+
from app.core.auth import optional_user_id
|
| 6 |
from app.schemas.blueprint import (
|
| 7 |
BlueprintCandidateResponse,
|
| 8 |
+
BlueprintChatRequest,
|
| 9 |
+
BlueprintChatResponse,
|
| 10 |
+
BlueprintDetails,
|
| 11 |
BlueprintGenerationRequest,
|
| 12 |
BlueprintResult,
|
| 13 |
+
BlueprintSavedResponse,
|
| 14 |
+
BlueprintSaveRequest,
|
| 15 |
+
DesignerCandidate,
|
| 16 |
+
DesignerCandidatesResponse,
|
| 17 |
)
|
| 18 |
from app.schemas.idea import IdeaRequest
|
| 19 |
+
from app.services.designer_client import DesignerClient, get_designer_client
|
| 20 |
+
from app.services.design_bundle_store import DesignBundleStore, get_design_bundle_store
|
| 21 |
from app.services.matrix_builder_service import MatrixBuilderService
|
| 22 |
from app.dependencies import get_matrix_builder_service
|
| 23 |
|
|
|
|
| 46 |
service: MatrixBuilderService = Depends(get_matrix_builder_service),
|
| 47 |
) -> BlueprintResult:
|
| 48 |
return service.generate_blueprint(payload.idea_request, candidate_id=payload.candidate_id)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# --- Matrix Designer: Blueprint Details page (fail-open via designer_client) -----------
|
| 52 |
+
@router.get("/designer-candidates", response_model=DesignerCandidatesResponse)
|
| 53 |
+
def designer_candidates(
|
| 54 |
+
idea: str = Query(..., description="The build idea to design the 3 blueprints for."),
|
| 55 |
+
designer: DesignerClient = Depends(get_designer_client),
|
| 56 |
+
) -> DesignerCandidatesResponse:
|
| 57 |
+
data = designer.blueprints(idea)
|
| 58 |
+
return DesignerCandidatesResponse(
|
| 59 |
+
candidates=[DesignerCandidate.model_validate(c) for c in data.get("candidates", [])]
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@router.get("/{candidate_id}/details", response_model=BlueprintDetails)
|
| 64 |
+
def blueprint_details(
|
| 65 |
+
candidate_id: str,
|
| 66 |
+
idea: str = Query(..., description="The build idea this blueprint is for."),
|
| 67 |
+
designer: DesignerClient = Depends(get_designer_client),
|
| 68 |
+
) -> BlueprintDetails:
|
| 69 |
+
return BlueprintDetails.model_validate(designer.details(idea, candidate_id))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@router.post("/{candidate_id}/chat", response_model=BlueprintChatResponse)
|
| 73 |
+
def blueprint_chat(
|
| 74 |
+
candidate_id: str,
|
| 75 |
+
payload: BlueprintChatRequest,
|
| 76 |
+
designer: DesignerClient = Depends(get_designer_client),
|
| 77 |
+
) -> BlueprintChatResponse:
|
| 78 |
+
out = designer.refine(payload.idea or "", payload.message, candidate_id)
|
| 79 |
+
details = out["details"].get(candidate_id) or out["details"].get("standard") or {}
|
| 80 |
+
return BlueprintChatResponse(reply=out.get("reply", ""), details=BlueprintDetails.model_validate(details))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@router.post("/{candidate_id}/save", response_model=BlueprintDetails)
|
| 84 |
+
def blueprint_save(
|
| 85 |
+
candidate_id: str,
|
| 86 |
+
payload: BlueprintSaveRequest,
|
| 87 |
+
owner_id: str = Depends(optional_user_id),
|
| 88 |
+
store: DesignBundleStore = Depends(get_design_bundle_store),
|
| 89 |
+
) -> BlueprintDetails:
|
| 90 |
+
# batch-10 — persist the edited blueprint + chat for this build, owner-scoped (RLS), so
|
| 91 |
+
# reopening the build restores it. The contract only changes when the user saves.
|
| 92 |
+
if payload.build_id:
|
| 93 |
+
store.save(
|
| 94 |
+
owner_id=owner_id, build_id=payload.build_id, candidate_id=candidate_id,
|
| 95 |
+
idea=payload.idea or "", details=payload.details.model_dump(),
|
| 96 |
+
chat_history=[m.model_dump() for m in payload.details.chat_history],
|
| 97 |
+
)
|
| 98 |
+
return payload.details
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@router.get("/{candidate_id}/saved", response_model=BlueprintSavedResponse)
|
| 102 |
+
def blueprint_saved(
|
| 103 |
+
candidate_id: str,
|
| 104 |
+
build_id: str = Query(..., description="The build to restore the saved blueprint for."),
|
| 105 |
+
owner_id: str = Depends(optional_user_id),
|
| 106 |
+
store: DesignBundleStore = Depends(get_design_bundle_store),
|
| 107 |
+
) -> BlueprintSavedResponse:
|
| 108 |
+
rec = store.get(owner_id, build_id, candidate_id)
|
| 109 |
+
if not rec:
|
| 110 |
+
return BlueprintSavedResponse(found=False)
|
| 111 |
+
return BlueprintSavedResponse(found=True, idea=rec.get("idea", ""),
|
| 112 |
+
details=BlueprintDetails.model_validate(rec["details"]))
|
services/api/app/db/orm.py
CHANGED
|
@@ -39,6 +39,7 @@ RLS_TABLES: tuple[str, ...] = (
|
|
| 39 |
"artifacts",
|
| 40 |
"run_events",
|
| 41 |
"gitpilot_runs",
|
|
|
|
| 42 |
)
|
| 43 |
|
| 44 |
|
|
@@ -265,6 +266,28 @@ class GitPilotRun(Base):
|
|
| 265 |
)
|
| 266 |
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
__all__ = [
|
| 269 |
"Base",
|
| 270 |
"RLS_TABLES",
|
|
@@ -279,4 +302,5 @@ __all__ = [
|
|
| 279 |
"RunEvent",
|
| 280 |
"Artifact",
|
| 281 |
"GitPilotRun",
|
|
|
|
| 282 |
]
|
|
|
|
| 39 |
"artifacts",
|
| 40 |
"run_events",
|
| 41 |
"gitpilot_runs",
|
| 42 |
+
"design_bundles",
|
| 43 |
)
|
| 44 |
|
| 45 |
|
|
|
|
| 266 |
)
|
| 267 |
|
| 268 |
|
| 269 |
+
class DesignBundleRecord(Base):
|
| 270 |
+
"""A saved Matrix Designer Blueprint Details + chat for a build (batch-10).
|
| 271 |
+
|
| 272 |
+
Owner-scoped like every other workflow table. Keyed by (owner, build, candidate)
|
| 273 |
+
so reopening a build restores the same blueprint and Talk-to-blueprint history.
|
| 274 |
+
"""
|
| 275 |
+
|
| 276 |
+
__tablename__ = "design_bundles"
|
| 277 |
+
id: Mapped[str] = _pk()
|
| 278 |
+
owner_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
| 279 |
+
build_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
| 280 |
+
candidate_id: Mapped[str] = mapped_column(String(32), nullable=False)
|
| 281 |
+
idea: Mapped[str] = mapped_column(Text, default="")
|
| 282 |
+
details: Mapped[dict] = mapped_column(JSONB, default=dict)
|
| 283 |
+
chat_history: Mapped[list] = mapped_column(JSONB, default=list)
|
| 284 |
+
created_at: Mapped[datetime] = _ts()
|
| 285 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 286 |
+
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
| 287 |
+
)
|
| 288 |
+
__table_args__ = (UniqueConstraint("owner_id", "build_id", "candidate_id", name="uq_design_bundle_owner_build_cand"),)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
__all__ = [
|
| 292 |
"Base",
|
| 293 |
"RLS_TABLES",
|
|
|
|
| 302 |
"RunEvent",
|
| 303 |
"Artifact",
|
| 304 |
"GitPilotRun",
|
| 305 |
+
"DesignBundleRecord",
|
| 306 |
]
|
services/api/app/schemas/blueprint.py
CHANGED
|
@@ -66,3 +66,91 @@ class BlueprintResult(StrictModel):
|
|
| 66 |
tasks: list[BlueprintTask] = Field(default_factory=list)
|
| 67 |
acceptance_commands: list[str] = Field(default_factory=list)
|
| 68 |
standards_lock_ref: str = "MATRIX_STANDARDS.lock"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
tasks: list[BlueprintTask] = Field(default_factory=list)
|
| 67 |
acceptance_commands: list[str] = Field(default_factory=list)
|
| 68 |
standards_lock_ref: str = "MATRIX_STANDARDS.lock"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# --- Matrix Designer: Blueprint Details (the dashboard the Details page renders) -------
|
| 72 |
+
# Lenient models (ignore extra keys) so designer output maps cleanly even as it evolves.
|
| 73 |
+
from pydantic import BaseModel # noqa: E402
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class DesignerArchitectureNode(BaseModel):
|
| 77 |
+
name: str
|
| 78 |
+
description: str = ""
|
| 79 |
+
dependencies: list[str] = Field(default_factory=list)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class DesignerFilePlanItem(BaseModel):
|
| 83 |
+
path: str
|
| 84 |
+
description: str = ""
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class DesignerBatch(BaseModel):
|
| 88 |
+
id: str
|
| 89 |
+
name: str
|
| 90 |
+
purpose: str = ""
|
| 91 |
+
tasks: list[str] = Field(default_factory=list)
|
| 92 |
+
allowed_files: list[str] = Field(default_factory=list)
|
| 93 |
+
depends_on: list[str] = Field(default_factory=list)
|
| 94 |
+
acceptance_criteria: list[str] = Field(default_factory=list)
|
| 95 |
+
validation_checks: list[str] = Field(default_factory=list)
|
| 96 |
+
must_not_change: list[str] = Field(default_factory=list)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class DesignerChatMessage(BaseModel):
|
| 100 |
+
id: str = ""
|
| 101 |
+
role: str = "blueprint"
|
| 102 |
+
content: str = ""
|
| 103 |
+
timestamp: str = ""
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class BlueprintDetails(BaseModel):
|
| 107 |
+
candidate_id: str
|
| 108 |
+
overview: str = ""
|
| 109 |
+
architecture: list[DesignerArchitectureNode] = Field(default_factory=list)
|
| 110 |
+
batches: list[DesignerBatch] = Field(default_factory=list)
|
| 111 |
+
file_plan: list[DesignerFilePlanItem] = Field(default_factory=list)
|
| 112 |
+
matrix_rules: list[str] = Field(default_factory=list)
|
| 113 |
+
acceptance_criteria: list[str] = Field(default_factory=list)
|
| 114 |
+
validation_plan: list[str] = Field(default_factory=list)
|
| 115 |
+
risks: list[str] = Field(default_factory=list)
|
| 116 |
+
assumptions: list[str] = Field(default_factory=list)
|
| 117 |
+
design_brain: str | None = None
|
| 118 |
+
chat_history: list[DesignerChatMessage] = Field(default_factory=list)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class BlueprintChatRequest(BaseModel):
|
| 122 |
+
message: str
|
| 123 |
+
idea: str | None = None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class BlueprintChatResponse(BaseModel):
|
| 127 |
+
reply: str
|
| 128 |
+
details: BlueprintDetails
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class BlueprintSaveRequest(BaseModel):
|
| 132 |
+
idea: str | None = None
|
| 133 |
+
build_id: str | None = None
|
| 134 |
+
details: BlueprintDetails
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class BlueprintSavedResponse(BaseModel):
|
| 138 |
+
found: bool = False
|
| 139 |
+
idea: str = ""
|
| 140 |
+
details: BlueprintDetails | None = None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class DesignerCandidate(BaseModel):
|
| 144 |
+
id: str
|
| 145 |
+
tier: str
|
| 146 |
+
title: str
|
| 147 |
+
summary: str = ""
|
| 148 |
+
file_count: int = 1
|
| 149 |
+
difficulty: str = "Medium"
|
| 150 |
+
estimate: str = "unknown"
|
| 151 |
+
stack: list[str] = Field(default_factory=list)
|
| 152 |
+
recommended: bool = False
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class DesignerCandidatesResponse(BaseModel):
|
| 156 |
+
candidates: list[DesignerCandidate] = Field(default_factory=list)
|
services/api/app/services/design_bundle_store.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistence for saved Matrix Designer blueprints + chat (batch-10).
|
| 2 |
+
|
| 3 |
+
Stores the chosen blueprint's Details and Talk-to-blueprint chat per build, owner-scoped,
|
| 4 |
+
so reopening a build restores the same state across devices.
|
| 5 |
+
|
| 6 |
+
Backed by Postgres when ``DATABASE_URL`` is configured AND the ``design_bundles`` table
|
| 7 |
+
exists (RLS-isolated per user); falls back to an in-memory store otherwise. Persistence is
|
| 8 |
+
best-effort and must never break the Details page, so a missing table degrades to memory.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import threading
|
| 15 |
+
import uuid
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from sqlalchemy import select
|
| 20 |
+
from sqlalchemy.exc import SQLAlchemyError
|
| 21 |
+
|
| 22 |
+
from app.db.engine import is_configured, session_scope
|
| 23 |
+
from app.db.orm import DesignBundleRecord
|
| 24 |
+
|
| 25 |
+
logger = logging.getLogger("matrix_builder.design_bundle_store")
|
| 26 |
+
|
| 27 |
+
# In-memory fallback, keyed by (owner, build, candidate) so isolation matches the DB's RLS.
|
| 28 |
+
_MEM: dict[tuple[str, str, str], dict[str, Any]] = {}
|
| 29 |
+
_LOCK = threading.Lock()
|
| 30 |
+
_DB_OK: bool | None = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _db_usable() -> bool:
|
| 34 |
+
global _DB_OK
|
| 35 |
+
if not is_configured():
|
| 36 |
+
return False
|
| 37 |
+
if _DB_OK is None:
|
| 38 |
+
try:
|
| 39 |
+
with session_scope() as session:
|
| 40 |
+
session.execute(select(DesignBundleRecord.id).limit(1))
|
| 41 |
+
_DB_OK = True
|
| 42 |
+
except SQLAlchemyError:
|
| 43 |
+
logger.warning("design_bundles table unavailable (migration not applied?); using in-memory store")
|
| 44 |
+
_DB_OK = False
|
| 45 |
+
return _DB_OK
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class DesignBundleStore:
|
| 49 |
+
def save(self, owner_id: str, build_id: str, candidate_id: str, idea: str,
|
| 50 |
+
details: dict[str, Any], chat_history: list[Any]) -> dict[str, Any]:
|
| 51 |
+
record = {
|
| 52 |
+
"owner_id": owner_id, "build_id": build_id, "candidate_id": candidate_id,
|
| 53 |
+
"idea": idea, "details": details, "chat_history": chat_history,
|
| 54 |
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
| 55 |
+
}
|
| 56 |
+
if _db_usable():
|
| 57 |
+
try:
|
| 58 |
+
with session_scope(user_id=owner_id) as session:
|
| 59 |
+
existing = session.execute(
|
| 60 |
+
select(DesignBundleRecord).where(
|
| 61 |
+
DesignBundleRecord.build_id == build_id,
|
| 62 |
+
DesignBundleRecord.candidate_id == candidate_id,
|
| 63 |
+
)
|
| 64 |
+
).scalar_one_or_none()
|
| 65 |
+
if existing is None:
|
| 66 |
+
session.add(DesignBundleRecord(
|
| 67 |
+
id=str(uuid.uuid4()), owner_id=owner_id, build_id=build_id,
|
| 68 |
+
candidate_id=candidate_id, idea=idea, details=details, chat_history=chat_history,
|
| 69 |
+
))
|
| 70 |
+
else:
|
| 71 |
+
existing.idea = idea
|
| 72 |
+
existing.details = details
|
| 73 |
+
existing.chat_history = chat_history
|
| 74 |
+
return record
|
| 75 |
+
except SQLAlchemyError:
|
| 76 |
+
logger.exception("design bundle save failed; falling back to memory")
|
| 77 |
+
with _LOCK:
|
| 78 |
+
_MEM[(owner_id, build_id, candidate_id)] = record
|
| 79 |
+
return record
|
| 80 |
+
|
| 81 |
+
def get(self, owner_id: str, build_id: str, candidate_id: str) -> dict[str, Any] | None:
|
| 82 |
+
if _db_usable():
|
| 83 |
+
try:
|
| 84 |
+
with session_scope(user_id=owner_id) as session:
|
| 85 |
+
row = session.execute(
|
| 86 |
+
select(DesignBundleRecord).where(
|
| 87 |
+
DesignBundleRecord.build_id == build_id,
|
| 88 |
+
DesignBundleRecord.candidate_id == candidate_id,
|
| 89 |
+
)
|
| 90 |
+
).scalar_one_or_none()
|
| 91 |
+
if row is not None:
|
| 92 |
+
return {"owner_id": row.owner_id, "build_id": row.build_id, "candidate_id": row.candidate_id,
|
| 93 |
+
"idea": row.idea, "details": row.details, "chat_history": row.chat_history}
|
| 94 |
+
return None
|
| 95 |
+
except SQLAlchemyError:
|
| 96 |
+
logger.exception("design bundle get failed; falling back to memory")
|
| 97 |
+
with _LOCK:
|
| 98 |
+
return _MEM.get((owner_id, build_id, candidate_id))
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
_STORE = DesignBundleStore()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def get_design_bundle_store() -> DesignBundleStore:
|
| 105 |
+
return _STORE
|
services/api/app/services/designer_client.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Designer client — bridges Matrix Builder's control plane to Matrix Designer.
|
| 2 |
+
|
| 3 |
+
Resolution order (fail-open, never 500):
|
| 4 |
+
1. HTTP service at ``MATRIX_DESIGNER_URL`` (the matrix-designer FastAPI service)
|
| 5 |
+
2. the ``matrix_designer`` package if importable in-process
|
| 6 |
+
3. a built-in deterministic fallback, so the Details page always has data even when
|
| 7 |
+
the brain is unavailable or the Matrix Designer toggle is off.
|
| 8 |
+
|
| 9 |
+
All three return the same shape: ``{candidates, details, matrix_rules}`` where ``details``
|
| 10 |
+
maps candidate_id → a Blueprint Details dict (the page's dashboard data).
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
from typing import Any, Dict, List, Optional
|
| 16 |
+
|
| 17 |
+
DESIGNER_URL = os.environ.get("MATRIX_DESIGNER_URL", "").rstrip("/")
|
| 18 |
+
RMD_RULES = [
|
| 19 |
+
"RMD-101: AI coders are workers, not architects.",
|
| 20 |
+
"RMD-103: Control files are protected.",
|
| 21 |
+
"RMD-111: Acceptance criteria are law.",
|
| 22 |
+
]
|
| 23 |
+
_TIERS = [
|
| 24 |
+
("minimal", "Minimal", "Easy", "a weekend", 0.55, False),
|
| 25 |
+
("standard", "Standard", "Medium", "about one week", 1.0, True),
|
| 26 |
+
("production", "Production", "Hard", "about three weeks", 1.6, False),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class DesignerClient:
|
| 31 |
+
def __init__(self, url: str = DESIGNER_URL) -> None:
|
| 32 |
+
self.url = url
|
| 33 |
+
|
| 34 |
+
# -- public API ---------------------------------------------------------------------
|
| 35 |
+
def blueprints(self, idea: str, references=None, constraints=None) -> Dict[str, Any]:
|
| 36 |
+
return self._call("/design/blueprints", {"idea": idea, "references": references, "constraints": constraints},
|
| 37 |
+
fallback=lambda: self._fallback_blueprints(idea))
|
| 38 |
+
|
| 39 |
+
def details(self, idea: str, candidate_id: str) -> Dict[str, Any]:
|
| 40 |
+
data = self.blueprints(idea)
|
| 41 |
+
return data["details"].get(candidate_id) or data["details"].get("standard") or {}
|
| 42 |
+
|
| 43 |
+
def refine(self, idea: str, message: str, candidate_id: str = "standard") -> Dict[str, Any]:
|
| 44 |
+
return self._call("/design/refine", {"idea": idea, "message": message, "candidate_id": candidate_id},
|
| 45 |
+
fallback=lambda: self._fallback_refine(idea, message, candidate_id))
|
| 46 |
+
|
| 47 |
+
# -- resolution ---------------------------------------------------------------------
|
| 48 |
+
def _call(self, path: str, payload: Dict[str, Any], fallback) -> Dict[str, Any]:
|
| 49 |
+
if self.url:
|
| 50 |
+
try:
|
| 51 |
+
import httpx # local import keeps it optional
|
| 52 |
+
r = httpx.post(f"{self.url}{path}", json=payload, timeout=8.0)
|
| 53 |
+
if r.status_code == 200:
|
| 54 |
+
data = r.json()
|
| 55 |
+
if not data.get("error"):
|
| 56 |
+
return data
|
| 57 |
+
except Exception:
|
| 58 |
+
pass # fall through to in-process / deterministic
|
| 59 |
+
try:
|
| 60 |
+
return self._via_import(path, payload)
|
| 61 |
+
except Exception:
|
| 62 |
+
return fallback()
|
| 63 |
+
|
| 64 |
+
def _via_import(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 65 |
+
from matrix_designer.graph import design_blueprints, refine, run_design # type: ignore
|
| 66 |
+
if path.endswith("blueprints"):
|
| 67 |
+
out = design_blueprints(payload["idea"], payload.get("references"), payload.get("constraints"))
|
| 68 |
+
out.pop("_state", None)
|
| 69 |
+
return out
|
| 70 |
+
state = run_design(payload["idea"])
|
| 71 |
+
out = refine(state, payload["message"], payload.get("candidate_id", "standard"))
|
| 72 |
+
out.pop("_state", None)
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
# -- deterministic fallback (self-contained) ---------------------------------------
|
| 76 |
+
def _fallback_blueprints(self, idea: str) -> Dict[str, Any]:
|
| 77 |
+
domain = self._domain(idea)
|
| 78 |
+
stack = self._stack(domain)
|
| 79 |
+
full = self._roadmap(domain)
|
| 80 |
+
candidates: List[Dict[str, Any]] = []
|
| 81 |
+
details: Dict[str, Any] = {}
|
| 82 |
+
for cid, tier, diff, est, scale, rec in _TIERS:
|
| 83 |
+
n = max(3, round(len(full) * scale))
|
| 84 |
+
batches = full[:n] if n <= len(full) else full + self._extra(len(full), n - len(full))
|
| 85 |
+
candidates.append({
|
| 86 |
+
"id": cid, "tier": tier,
|
| 87 |
+
"title": f"{tier} {'Matrix Bundle' if cid == 'standard' else 'controlled blueprint'}",
|
| 88 |
+
"summary": f"{'Recommended ' if rec else ''}controlled blueprint for: {idea.strip()[:90]}",
|
| 89 |
+
"file_count": max(8, round(len(self._files(domain)) * (2 + scale * 4))),
|
| 90 |
+
"difficulty": diff, "estimate": est, "stack": stack, "recommended": rec,
|
| 91 |
+
})
|
| 92 |
+
details[cid] = {
|
| 93 |
+
"candidate_id": cid,
|
| 94 |
+
"overview": f"This {tier.lower()} blueprint builds {idea.strip()[:120]}. Delivered in ordered, validated batches.",
|
| 95 |
+
"architecture": self._architecture(domain) if cid != "minimal" else self._architecture(domain)[:2],
|
| 96 |
+
"batches": batches,
|
| 97 |
+
"file_plan": self._files(domain),
|
| 98 |
+
"matrix_rules": RMD_RULES,
|
| 99 |
+
"acceptance_criteria": ["builds and runs", "core flow works with tests", "Matrix validation approves the build"],
|
| 100 |
+
"validation_plan": ["lint", "typecheck", "unit tests", "allowed-file check", "Matrix commit check"],
|
| 101 |
+
"risks": ["external credentials may be required"],
|
| 102 |
+
"assumptions": ["original assets only" if domain == "web-game" else "auth required if data is sensitive"],
|
| 103 |
+
"design_brain": f"Deterministic fallback · {len(batches)} batches · tier {tier}.",
|
| 104 |
+
"chat_history": [],
|
| 105 |
+
}
|
| 106 |
+
return {"candidates": candidates, "details": details, "matrix_rules": RMD_RULES, "violations": []}
|
| 107 |
+
|
| 108 |
+
def _fallback_refine(self, idea: str, message: str, candidate_id: str) -> Dict[str, Any]:
|
| 109 |
+
data = self._fallback_blueprints(idea)
|
| 110 |
+
det = data["details"].get(candidate_id, data["details"]["standard"])
|
| 111 |
+
m = message.lower()
|
| 112 |
+
if any(k in m for k in ("add", "boss", "level", "analytic", "audit", "auth", "dashboard")):
|
| 113 |
+
nid = f"batch-{len(det['batches']) + 1:02d}"
|
| 114 |
+
name = "Boss encounter" if "boss" in m else "Refinement"
|
| 115 |
+
det["batches"].append({"id": nid, "name": name, "purpose": f"Added from chat: {message.strip()}",
|
| 116 |
+
"tasks": [message.strip()], "allowed_files": ["src/**"], "depends_on": [],
|
| 117 |
+
"acceptance_criteria": ["feature works", "no runtime errors"], "validation_checks": ["unit tests"], "must_not_change": []})
|
| 118 |
+
reply = f"Added {nid} — {name}. Review and Save latest edits."
|
| 119 |
+
elif any(k in m for k in ("reduce", "simpler", "remove", "drop")) and len(det["batches"]) > 3:
|
| 120 |
+
dropped = det["batches"].pop()
|
| 121 |
+
reply = f"Reduced scope — removed {dropped['name']}."
|
| 122 |
+
else:
|
| 123 |
+
reply = "Noted. Save latest edits to keep this."
|
| 124 |
+
return {"reply": reply, "details": data["details"], "candidates": data["candidates"]}
|
| 125 |
+
|
| 126 |
+
# -- tiny domain heuristics --------------------------------------------------------
|
| 127 |
+
@staticmethod
|
| 128 |
+
def _domain(idea: str) -> str:
|
| 129 |
+
s = idea.lower()
|
| 130 |
+
if any(k in s for k in ("game", "platformer", "phaser", "arcade")):
|
| 131 |
+
return "web-game"
|
| 132 |
+
if any(k in s for k in ("api", "fastapi", "service", "endpoint")):
|
| 133 |
+
return "api"
|
| 134 |
+
return "web-app"
|
| 135 |
+
|
| 136 |
+
@staticmethod
|
| 137 |
+
def _stack(domain: str) -> List[str]:
|
| 138 |
+
return {"web-game": ["Phaser 3", "TypeScript", "Vite"], "api": ["FastAPI", "PostgreSQL", "Docker"]}.get(
|
| 139 |
+
domain, ["Next.js", "FastAPI", "PostgreSQL", "Docker"])
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
def _architecture(domain: str) -> List[Dict[str, Any]]:
|
| 143 |
+
if domain == "web-game":
|
| 144 |
+
return [{"name": "Web Client (Phaser)", "description": "Canvas game", "dependencies": []},
|
| 145 |
+
{"name": "Asset pipeline", "description": "Original art", "dependencies": ["Web Client (Phaser)"]},
|
| 146 |
+
{"name": "Level engine", "description": "Data-driven levels", "dependencies": ["Web Client (Phaser)"]},
|
| 147 |
+
{"name": "CI / Pages", "description": "Build + deploy", "dependencies": []}]
|
| 148 |
+
return [{"name": "Web App", "description": "Frontend", "dependencies": []},
|
| 149 |
+
{"name": "API", "description": "Business logic", "dependencies": ["Web App"]},
|
| 150 |
+
{"name": "Database", "description": "Storage", "dependencies": ["API"]},
|
| 151 |
+
{"name": "Worker / Queue", "description": "Background jobs", "dependencies": ["API"]},
|
| 152 |
+
{"name": "Admin Dashboard", "description": "Ops & analytics", "dependencies": ["Web App", "API"]}]
|
| 153 |
+
|
| 154 |
+
@staticmethod
|
| 155 |
+
def _files(domain: str) -> List[Dict[str, str]]:
|
| 156 |
+
if domain == "web-game":
|
| 157 |
+
return [{"path": p, "description": d} for p, d in [
|
| 158 |
+
("src/scenes", "Boot/Preload/Game/…"), ("src/levels", "level data + builder"),
|
| 159 |
+
("src/entities", "Player, enemies, Coin"), ("public/assets", "original art"), ("README.md", "overview")]]
|
| 160 |
+
return [{"path": p, "description": d} for p, d in [
|
| 161 |
+
("apps/web", "Web app and dashboard"), ("services/api", "API and business logic"),
|
| 162 |
+
("services/worker", "Background jobs"), ("packages/shared", "Shared types"), ("db", "Migrations"), ("README.md", "overview")]]
|
| 163 |
+
|
| 164 |
+
@staticmethod
|
| 165 |
+
def _roadmap(domain: str) -> List[Dict[str, Any]]:
|
| 166 |
+
if domain == "web-game":
|
| 167 |
+
spec = [("Game foundation", ["vite.config.ts", "src/main.ts"]), ("Asset pipeline", ["scripts/gen_assets.py", "public/assets/**"]),
|
| 168 |
+
("Tilemap world", ["src/levels/**"]), ("Player & controls", ["src/entities/Player.ts"]),
|
| 169 |
+
("Enemies & power-ups", ["src/entities/**"]), ("HUD & gate", ["src/ui/HUD.ts"]),
|
| 170 |
+
("Campaign & boss", ["src/levels/episodes.ts"]), ("Polish & release", [".github/workflows/deploy.yml"])]
|
| 171 |
+
else:
|
| 172 |
+
spec = [("Foundation", ["apps/web/**", "services/api/**"]), ("Integrations", ["services/api/**"]),
|
| 173 |
+
("Workflow & logic", ["services/api/**"]), ("Admin & analytics", ["apps/web/**"]),
|
| 174 |
+
("Hardening & deploy", ["docker-compose.yml", ".github/**"]), ("Validation & release", ["tests/**"])]
|
| 175 |
+
out = []
|
| 176 |
+
for i, (name, allowed) in enumerate(spec, 1):
|
| 177 |
+
out.append({"id": f"batch-{i:02d}", "name": name, "purpose": f"{name}.", "tasks": [name],
|
| 178 |
+
"allowed_files": allowed, "depends_on": [f"batch-{i-1:02d}"] if i > 1 else [],
|
| 179 |
+
"acceptance_criteria": ["builds", "feature works", "no runtime errors"],
|
| 180 |
+
"validation_checks": ["lint", "typecheck", "tests"], "must_not_change": []})
|
| 181 |
+
return out
|
| 182 |
+
|
| 183 |
+
@staticmethod
|
| 184 |
+
def _extra(start: int, count: int) -> List[Dict[str, Any]]:
|
| 185 |
+
names = ["Observability", "Performance pass", "Security review", "Docs & onboarding", "Accessibility"]
|
| 186 |
+
out = []
|
| 187 |
+
for k in range(count):
|
| 188 |
+
i = start + k + 1
|
| 189 |
+
out.append({"id": f"batch-{i:02d}", "name": names[k % len(names)], "purpose": "Production hardening.",
|
| 190 |
+
"tasks": ["hardening"], "allowed_files": ["**"], "depends_on": [f"batch-{i-1:02d}"],
|
| 191 |
+
"acceptance_criteria": ["meets the production bar"], "validation_checks": ["tests"], "must_not_change": []})
|
| 192 |
+
return out
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def get_designer_client() -> DesignerClient:
|
| 196 |
+
return DesignerClient()
|
services/api/migrations/versions/0006_design_bundles.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Saved Matrix Designer Blueprint Details + chat per build (batch-10).
|
| 2 |
+
|
| 3 |
+
Owner-scoped table that persists the chosen blueprint's details and the
|
| 4 |
+
Talk-to-blueprint chat history for a build, so reopening it restores the same
|
| 5 |
+
state across devices — RLS-isolated per user like every other workflow table.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from alembic import op
|
| 11 |
+
|
| 12 |
+
revision = "0006_design_bundles"
|
| 13 |
+
down_revision = "0005_gitpilot_runs"
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
_TABLE_SQL = """
|
| 18 |
+
CREATE TABLE IF NOT EXISTS design_bundles (
|
| 19 |
+
id text PRIMARY KEY,
|
| 20 |
+
owner_id text NOT NULL,
|
| 21 |
+
build_id text NOT NULL,
|
| 22 |
+
candidate_id text NOT NULL,
|
| 23 |
+
idea text NOT NULL DEFAULT '',
|
| 24 |
+
details jsonb NOT NULL DEFAULT '{}'::jsonb,
|
| 25 |
+
chat_history jsonb NOT NULL DEFAULT '[]'::jsonb,
|
| 26 |
+
created_at timestamptz NOT NULL DEFAULT now(),
|
| 27 |
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
| 28 |
+
CONSTRAINT uq_design_bundle_owner_build_cand UNIQUE (owner_id, build_id, candidate_id)
|
| 29 |
+
)
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def upgrade() -> None:
|
| 34 |
+
op.execute(_TABLE_SQL)
|
| 35 |
+
op.execute("CREATE INDEX IF NOT EXISTS ix_design_bundles_owner ON design_bundles(owner_id)")
|
| 36 |
+
op.execute(
|
| 37 |
+
"CREATE INDEX IF NOT EXISTS ix_design_bundles_owner_build "
|
| 38 |
+
"ON design_bundles(owner_id, build_id)"
|
| 39 |
+
)
|
| 40 |
+
op.execute("ALTER TABLE design_bundles ENABLE ROW LEVEL SECURITY")
|
| 41 |
+
op.execute("ALTER TABLE design_bundles FORCE ROW LEVEL SECURITY")
|
| 42 |
+
op.execute(
|
| 43 |
+
"CREATE POLICY design_bundles_owner ON design_bundles "
|
| 44 |
+
"USING (owner_id = app_current_user()) WITH CHECK (owner_id = app_current_user())"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def downgrade() -> None:
|
| 49 |
+
op.execute("DROP TABLE IF EXISTS design_bundles CASCADE")
|
start.sh
CHANGED
|
@@ -1,7 +1,20 @@
|
|
| 1 |
#!/usr/bin/env sh
|
| 2 |
set -e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
# Backend (internal, same-origin target of the Next.js /api/builder rewrite).
|
| 4 |
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --proxy-headers &
|
|
|
|
| 5 |
# Frontend (public). Next standalone honors PORT/HOSTNAME.
|
| 6 |
cd /app/web
|
| 7 |
export PORT="${PORT:-7860}" HOSTNAME=0.0.0.0
|
|
|
|
| 1 |
#!/usr/bin/env sh
|
| 2 |
set -e
|
| 3 |
+
# Matrix Designer (the brain) — internal HTTP service the control plane calls to design the
|
| 4 |
+
# blueprints/batches. Best-effort: if it fails to boot, the control plane falls back to its
|
| 5 |
+
# deterministic generator, so the app still works. Bound to localhost; never public.
|
| 6 |
+
export MATRIX_DESIGNER_PORT="${MATRIX_DESIGNER_PORT:-8077}"
|
| 7 |
+
export MATRIX_DESIGNER_URL="${MATRIX_DESIGNER_URL:-http://127.0.0.1:${MATRIX_DESIGNER_PORT}}"
|
| 8 |
+
if python -c "import matrix_designer" 2>/dev/null; then
|
| 9 |
+
echo "-> starting Matrix Designer service on 127.0.0.1:${MATRIX_DESIGNER_PORT}"
|
| 10 |
+
( cd /tmp && python -m matrix_designer.service ) &
|
| 11 |
+
else
|
| 12 |
+
echo "-> Matrix Designer not installed; control plane will use the deterministic generator"
|
| 13 |
+
fi
|
| 14 |
+
|
| 15 |
# Backend (internal, same-origin target of the Next.js /api/builder rewrite).
|
| 16 |
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --proxy-headers &
|
| 17 |
+
|
| 18 |
# Frontend (public). Next standalone honors PORT/HOSTNAME.
|
| 19 |
cd /app/web
|
| 20 |
export PORT="${PORT:-7860}" HOSTNAME=0.0.0.0
|
web/src/app/matrix-builder/MatrixBuilderClient.tsx
CHANGED
|
@@ -12,6 +12,11 @@ import { createBundleFiles } from "@/lib/matrix-bundle";
|
|
| 12 |
import { createBlueprintCandidates } from "@/lib/matrix-demo-data";
|
| 13 |
import { toUiBundleFiles, toUiCandidates } from "@/lib/engine-map";
|
| 14 |
import { briefToIdea, enhanceProjectBrief, enrichBlueprintCandidates, explainValidationFindings, isOllaBridgeAssistAvailable, type EnrichmentMap } from "@/lib/ai-provider-manager";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
import UploadExistingPlanModal, { type UploadSelection } from "@/components/matrix-builder/UploadExistingPlanModal";
|
| 16 |
import {
|
| 17 |
downloadBundleZip,
|
|
@@ -54,7 +59,7 @@ import type { ProjectBriefContract } from "@/lib/workflow-types";
|
|
| 54 |
import type { CoderId } from "@/types/coder";
|
| 55 |
import type { ValidationReportContract } from "@/types/contracts";
|
| 56 |
|
| 57 |
-
type Phase = "hero" | "scanning" | "candidates" | "bundle" | "submit" | "running" | "validation" | "timeline" | "complete";
|
| 58 |
|
| 59 |
// Paths the AI coder reports as changed (parsed from the pasted result) → validated against the
|
| 60 |
// contract. status defaults to "modified".
|
|
@@ -736,7 +741,7 @@ function LandingHero({ idea, setIdea, generate, onUpload }: { idea: string; setI
|
|
| 736 |
);
|
| 737 |
}
|
| 738 |
|
| 739 |
-
function CandidateCard({ candidate, choose, enrichment }: { candidate: BlueprintCandidate; choose: (candidate: BlueprintCandidate) => void; enrichment?: { displayName?: string; displaySummary?: string } }) {
|
| 740 |
// Display-only: the deterministic candidate still drives bundle generation. AI may only soften
|
| 741 |
// the visible name/summary; an explicit badge tells the user the wording was AI-assisted.
|
| 742 |
const name = enrichment?.displayName ?? candidate.name;
|
|
@@ -755,7 +760,19 @@ function CandidateCard({ candidate, choose, enrichment }: { candidate: Blueprint
|
|
| 755 |
<span className="mb-tag n">{candidate.time}</span>
|
| 756 |
</div>
|
| 757 |
<div className="cand-stack">{candidate.stack.map((stack) => <span className="mb-tag" key={stack}>{stack}</span>)}</div>
|
| 758 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
</article>
|
| 760 |
);
|
| 761 |
}
|
|
@@ -1584,12 +1601,280 @@ function BuildComplete({
|
|
| 1584 |
);
|
| 1585 |
}
|
| 1586 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1587 |
export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: InitialBuild } = {}) {
|
| 1588 |
// When reopened from My Builds we land directly on the active build screen, reconstructed
|
| 1589 |
// from the persisted build, so the user keeps full context (no state is lost).
|
| 1590 |
const [phase, setPhase] = useState<Phase>(initialBuild ? "bundle" : "hero");
|
| 1591 |
const [idea, setIdea] = useState(initialBuild?.idea ?? "");
|
| 1592 |
const [scanIndex, setScanIndex] = useState(0);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1593 |
const [candidates, setCandidates] = useState<BlueprintCandidate[]>([]);
|
| 1594 |
const [candidatesLoaded, setCandidatesLoaded] = useState(false);
|
| 1595 |
// Optional Internal AI: display-only copy overrides keyed by candidate id; the deterministic
|
|
@@ -1689,6 +1974,11 @@ export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: I
|
|
| 1689 |
}
|
| 1690 |
setCandidates(list);
|
| 1691 |
setCandidatesLoaded(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1692 |
// Optional Internal AI assist (fail-open): improve display copy only, after candidates show.
|
| 1693 |
void enrichBlueprintCandidates(
|
| 1694 |
ideaText,
|
|
@@ -1712,6 +2002,14 @@ export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: I
|
|
| 1712 |
}, [phase, scanIndex, candidatesLoaded]);
|
| 1713 |
|
| 1714 |
// Step 2 — generate the chosen candidate's Matrix Bundle on the engine (the real file manifest).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1715 |
const choose = (candidate: BlueprintCandidate) => {
|
| 1716 |
const localId = generateBundleId();
|
| 1717 |
setChosen(candidate);
|
|
@@ -2098,12 +2396,28 @@ export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: I
|
|
| 2098 |
</details>
|
| 2099 |
)}
|
| 2100 |
<div className="cand-grid stag">
|
| 2101 |
-
{candidates.map((candidate) => <CandidateCard key={candidate.id} candidate={candidate} choose={choose} enrichment={enrichedById[candidate.id]} />)}
|
| 2102 |
</div>
|
| 2103 |
</div>
|
| 2104 |
</>
|
| 2105 |
)}
|
| 2106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2107 |
{phase === "bundle" && chosen && (
|
| 2108 |
<BundleResult
|
| 2109 |
candidate={chosen}
|
|
|
|
| 12 |
import { createBlueprintCandidates } from "@/lib/matrix-demo-data";
|
| 13 |
import { toUiBundleFiles, toUiCandidates } from "@/lib/engine-map";
|
| 14 |
import { briefToIdea, enhanceProjectBrief, enrichBlueprintCandidates, explainValidationFindings, isOllaBridgeAssistAvailable, type EnrichmentMap } from "@/lib/ai-provider-manager";
|
| 15 |
+
import { isMatrixDesignerEnabled } from "@/lib/ai-settings-store";
|
| 16 |
+
import { fetchBlueprintDetails, saveBlueprintDetails, type DesignerCandidate } from "@/lib/blueprint-client";
|
| 17 |
+
import { getCapabilities } from "@/lib/capabilities";
|
| 18 |
+
import { generateCandidates } from "@/lib/blueprint-engine";
|
| 19 |
+
import { useBlueprintWorkspace } from "@/lib/blueprint-store";
|
| 20 |
import UploadExistingPlanModal, { type UploadSelection } from "@/components/matrix-builder/UploadExistingPlanModal";
|
| 21 |
import {
|
| 22 |
downloadBundleZip,
|
|
|
|
| 59 |
import type { CoderId } from "@/types/coder";
|
| 60 |
import type { ValidationReportContract } from "@/types/contracts";
|
| 61 |
|
| 62 |
+
type Phase = "hero" | "scanning" | "candidates" | "details" | "bundle" | "submit" | "running" | "validation" | "timeline" | "complete";
|
| 63 |
|
| 64 |
// Paths the AI coder reports as changed (parsed from the pasted result) → validated against the
|
| 65 |
// contract. status defaults to "modified".
|
|
|
|
| 741 |
);
|
| 742 |
}
|
| 743 |
|
| 744 |
+
function CandidateCard({ candidate, choose, details, enrichment }: { candidate: BlueprintCandidate; choose: (candidate: BlueprintCandidate) => void; details: (candidate: BlueprintCandidate) => void; enrichment?: { displayName?: string; displaySummary?: string } }) {
|
| 745 |
// Display-only: the deterministic candidate still drives bundle generation. AI may only soften
|
| 746 |
// the visible name/summary; an explicit badge tells the user the wording was AI-assisted.
|
| 747 |
const name = enrichment?.displayName ?? candidate.name;
|
|
|
|
| 760 |
<span className="mb-tag n">{candidate.time}</span>
|
| 761 |
</div>
|
| 762 |
<div className="cand-stack">{candidate.stack.map((stack) => <span className="mb-tag" key={stack}>{stack}</span>)}</div>
|
| 763 |
+
{/* Secondary action: inspect the full plan before choosing. Primary stays "Choose this". */}
|
| 764 |
+
<div className="cand-actions">
|
| 765 |
+
<button
|
| 766 |
+
className="cand-details"
|
| 767 |
+
type="button"
|
| 768 |
+
onClick={(e) => { e.stopPropagation(); details(candidate); }}
|
| 769 |
+
>
|
| 770 |
+
Details
|
| 771 |
+
</button>
|
| 772 |
+
<button className="cand-choose" type="button" onClick={(e) => { e.stopPropagation(); choose(candidate); }}>
|
| 773 |
+
Choose this <MatrixIcon size={16}>{icons.arrow}</MatrixIcon>
|
| 774 |
+
</button>
|
| 775 |
+
</div>
|
| 776 |
</article>
|
| 777 |
);
|
| 778 |
}
|
|
|
|
| 1601 |
);
|
| 1602 |
}
|
| 1603 |
|
| 1604 |
+
// ---------------------------------------------------------------------------------------------
|
| 1605 |
+
// Blueprint Details — a calm planning room for one blueprint. Cards stay simple; this page answers
|
| 1606 |
+
// "what exactly will this path build?". All sections are derived client-side from the deterministic
|
| 1607 |
+
// candidate + the idea (no backend route yet); the Matrix contract is never changed here.
|
| 1608 |
+
// ---------------------------------------------------------------------------------------------
|
| 1609 |
+
|
| 1610 |
+
// Map a Matrix Designer candidate card onto the UI's BlueprintCandidate shape.
|
| 1611 |
+
function designerToUiCandidate(dc: DesignerCandidate): BlueprintCandidate {
|
| 1612 |
+
return {
|
| 1613 |
+
id: dc.id as BlueprintCandidate["id"],
|
| 1614 |
+
tier: dc.tier as BlueprintCandidate["tier"],
|
| 1615 |
+
name: dc.title,
|
| 1616 |
+
summary: dc.summary,
|
| 1617 |
+
stack: dc.stack,
|
| 1618 |
+
files: dc.file_count,
|
| 1619 |
+
difficulty: dc.difficulty as BlueprintCandidate["difficulty"],
|
| 1620 |
+
time: dc.estimate,
|
| 1621 |
+
standards: [],
|
| 1622 |
+
recommended: dc.recommended,
|
| 1623 |
+
};
|
| 1624 |
+
}
|
| 1625 |
+
|
| 1626 |
+
// Map an architecture-node name to one of the existing line icons.
|
| 1627 |
+
function archIcon(name: string): IconDefinition {
|
| 1628 |
+
const s = name.toLowerCase();
|
| 1629 |
+
if (/api|backend|service/.test(s)) return icons.cpu;
|
| 1630 |
+
if (/data|db|postgres|mysql|storage|mongo/.test(s)) return icons.cube;
|
| 1631 |
+
if (/worker|queue|job/.test(s)) return icons.refresh;
|
| 1632 |
+
if (/deploy|ci|pages|docker|kubernetes/.test(s)) return icons.shield;
|
| 1633 |
+
if (/dashboard|admin|ui|doc/.test(s)) return icons.doc;
|
| 1634 |
+
return icons.layers;
|
| 1635 |
+
}
|
| 1636 |
+
|
| 1637 |
+
function BlueprintDetails({ candidate, subject, idea, designerOn, aiAssist, onBack, onChoose, onNotice }: {
|
| 1638 |
+
candidate: BlueprintCandidate; subject: string; idea: string; designerOn: boolean; aiAssist: boolean;
|
| 1639 |
+
onBack: () => void; onChoose: () => void; onNotice: (message: string) => void;
|
| 1640 |
+
}) {
|
| 1641 |
+
// C0/C1/C2 — the workspace is a single client state object mutated by the in-browser engine.
|
| 1642 |
+
// No fetch on the render path: generate + chat run locally, so the page is instant and works
|
| 1643 |
+
// fully offline. (The server/LLM are optional enhancements, layered on via ws.setData later.)
|
| 1644 |
+
const ws = useBlueprintWorkspace(candidate.id, idea);
|
| 1645 |
+
const { data, messages, busy, dirty } = ws;
|
| 1646 |
+
const [draft, setDraft] = useState("");
|
| 1647 |
+
const [chatTab, setChatTab] = useState<"chat" | "activity">("chat");
|
| 1648 |
+
|
| 1649 |
+
// C5 — optional server sync behind a capability check. With no backend (offline / static
|
| 1650 |
+
// hosting) this is a no-op and the UX is identical; with a backend it syncs the richer output.
|
| 1651 |
+
useEffect(() => {
|
| 1652 |
+
let active = true;
|
| 1653 |
+
void getCapabilities().then((cap) => {
|
| 1654 |
+
if (!active || !cap.server) return; // no server → stay fully local
|
| 1655 |
+
if (ws.data.chat_history.length > 0) return; // don't clobber local edits
|
| 1656 |
+
void fetchBlueprintDetails(candidate.id, idea, { useDesigner: designerOn })
|
| 1657 |
+
.then((d) => { if (active) ws.setData(d); })
|
| 1658 |
+
.catch(() => undefined);
|
| 1659 |
+
});
|
| 1660 |
+
return () => { active = false; };
|
| 1661 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 1662 |
+
}, [candidate.id, idea, designerOn]);
|
| 1663 |
+
|
| 1664 |
+
function send() {
|
| 1665 |
+
const text = draft.trim();
|
| 1666 |
+
if (!text) return;
|
| 1667 |
+
ws.applyInstruction(text); // instant, local, no network — sections patch from the engine
|
| 1668 |
+
setDraft("");
|
| 1669 |
+
}
|
| 1670 |
+
|
| 1671 |
+
function save() {
|
| 1672 |
+
if (!dirty) { onNotice("No changes to save."); return; }
|
| 1673 |
+
// Local state is already saved (localStorage). Server persistence is gated on a capability
|
| 1674 |
+
// check so offline makes zero requests.
|
| 1675 |
+
void getCapabilities().then((cap) => {
|
| 1676 |
+
if (cap.server) void saveBlueprintDetails(candidate.id, idea, data).catch(() => undefined);
|
| 1677 |
+
});
|
| 1678 |
+
ws.markSaved();
|
| 1679 |
+
onNotice("Blueprint updated.");
|
| 1680 |
+
}
|
| 1681 |
+
|
| 1682 |
+
// Activity feed derived from the live state — what the engine changed.
|
| 1683 |
+
const activity = [
|
| 1684 |
+
`${data.batches.length} build batches planned`,
|
| 1685 |
+
`${data.architecture.length} architecture components`,
|
| 1686 |
+
`${data.file_plan.length} planned files`,
|
| 1687 |
+
...messages.filter((m) => m.role === "blueprint").map((m) => m.content),
|
| 1688 |
+
];
|
| 1689 |
+
|
| 1690 |
+
const batches = data.batches;
|
| 1691 |
+
const architecture = data.architecture;
|
| 1692 |
+
const filePlan = data.file_plan;
|
| 1693 |
+
|
| 1694 |
+
return (
|
| 1695 |
+
<div className="bd-surface">
|
| 1696 |
+
<div className="l-wrap mb-page bd-page">
|
| 1697 |
+
<div className="bd-crumb reveal">
|
| 1698 |
+
<span className="bd-step">Step 1 · Choose a blueprint</span>
|
| 1699 |
+
<span className="bd-sep">/</span><span>{candidate.tier}</span>
|
| 1700 |
+
<span className="bd-sep">/</span><span className="bd-cur">Details</span>
|
| 1701 |
+
</div>
|
| 1702 |
+
|
| 1703 |
+
<div className="bd-head reveal">
|
| 1704 |
+
<div className="bd-head-main">
|
| 1705 |
+
<h1 className="bd-h1">{candidate.name}{candidate.recommended && <span className="bd-rec">Recommended</span>}</h1>
|
| 1706 |
+
<p className="bd-lede">{candidate.summary}</p>
|
| 1707 |
+
<div className="bd-pills">
|
| 1708 |
+
<span className="mb-tag">Stack: {candidate.stack.join(", ")}</span>
|
| 1709 |
+
<span className="mb-tag n">{candidate.files} files</span>
|
| 1710 |
+
<span className="mb-tag n">{candidate.difficulty}</span>
|
| 1711 |
+
<span className="mb-tag n">{candidate.time}</span>
|
| 1712 |
+
{aiAssist && <span className="mb-tag bd-ai">AI assist</span>}
|
| 1713 |
+
<span className="mb-tag bd-lock"><MatrixIcon size={12}>{icons.shield}</MatrixIcon> RMD locked</span>
|
| 1714 |
+
</div>
|
| 1715 |
+
</div>
|
| 1716 |
+
</div>
|
| 1717 |
+
|
| 1718 |
+
<div className="bd-grid">
|
| 1719 |
+
{/* LEFT — overview, batches, chat (history + bottom composer) */}
|
| 1720 |
+
<div className="bd-col">
|
| 1721 |
+
<section className="bd-card reveal">
|
| 1722 |
+
<div className="bd-card-h"><MatrixIcon size={16}>{icons.doc}</MatrixIcon><h3>Blueprint overview</h3></div>
|
| 1723 |
+
<div className="bd-ov-split">
|
| 1724 |
+
<p className="bd-p">{data.overview}</p>
|
| 1725 |
+
<div className="bd-ov">
|
| 1726 |
+
<div className="bd-ov-scope"><span className="bd-ov-k">Scope</span><span className="bd-ov-v">{idea}</span></div>
|
| 1727 |
+
<div className="bd-ov-row">
|
| 1728 |
+
<div><span className="bd-ov-k">Quality</span><span className="bd-ov-v">{candidate.tier}</span></div>
|
| 1729 |
+
<div><span className="bd-ov-k">Outcome</span><span className="bd-ov-v">{data.acceptance_criteria[0] ?? "builds and runs"}</span></div>
|
| 1730 |
+
</div>
|
| 1731 |
+
</div>
|
| 1732 |
+
</div>
|
| 1733 |
+
</section>
|
| 1734 |
+
|
| 1735 |
+
<section className="bd-card reveal">
|
| 1736 |
+
<div className="bd-card-h"><MatrixIcon size={16}>{icons.layers}</MatrixIcon><h3>Build batches</h3>
|
| 1737 |
+
<span className="bd-card-sub">{`${batches.length} controlled batches · built in order`}</span></div>
|
| 1738 |
+
<ol className="bd-batches">
|
| 1739 |
+
{batches.map((b, i) => (
|
| 1740 |
+
<li className="bd-batch" key={b.id}>
|
| 1741 |
+
<span className="bd-batch-n">{String(i + 1).padStart(2, "0")}</span>
|
| 1742 |
+
<div className="bd-batch-body">
|
| 1743 |
+
<div className="bd-batch-top">
|
| 1744 |
+
<span className="bd-batch-name">{b.name}</span>
|
| 1745 |
+
<span className="bd-batch-desc">{b.purpose}</span>
|
| 1746 |
+
</div>
|
| 1747 |
+
<div className="bd-batch-meta">
|
| 1748 |
+
<span className="bd-batch-tag">Allowed: {b.allowed_files.slice(0, 2).join(", ")}{b.allowed_files.length > 2 ? "…" : ""}</span>
|
| 1749 |
+
{b.acceptance_criteria[0] && <span className="bd-batch-tag">Accept: {b.acceptance_criteria[0]}</span>}
|
| 1750 |
+
</div>
|
| 1751 |
+
</div>
|
| 1752 |
+
<span className="bd-batch-status"><span className="bd-status-dot" />Planned</span>
|
| 1753 |
+
</li>
|
| 1754 |
+
))}
|
| 1755 |
+
</ol>
|
| 1756 |
+
</section>
|
| 1757 |
+
|
| 1758 |
+
{/* Chat — history + always-visible bottom composer (ChatGPT/Claude style) */}
|
| 1759 |
+
<section className="bd-card reveal bd-chat">
|
| 1760 |
+
<div className="bd-chat-head">
|
| 1761 |
+
<div className="bd-chat-tabs">
|
| 1762 |
+
<button className={`bd-tab${chatTab === "chat" ? " on" : ""}`} type="button" onClick={() => setChatTab("chat")}>Chat</button>
|
| 1763 |
+
<button className={`bd-tab${chatTab === "activity" ? " on" : ""}`} type="button" onClick={() => setChatTab("activity")}>Activity</button>
|
| 1764 |
+
</div>
|
| 1765 |
+
{dirty && <span className="bd-unsaved" title="You have unsaved edits">Unsaved</span>}
|
| 1766 |
+
<button className={`bd-save${dirty ? " on" : ""}`} type="button" onClick={save} title="Save latest edits" aria-label="Save latest edits">
|
| 1767 |
+
<MatrixIcon size={15}>{icons.download}</MatrixIcon>
|
| 1768 |
+
</button>
|
| 1769 |
+
</div>
|
| 1770 |
+
|
| 1771 |
+
<div className="bd-chat-log">
|
| 1772 |
+
{chatTab === "chat" ? (
|
| 1773 |
+
messages.length === 0 ? (
|
| 1774 |
+
<p className="bd-chat-empty">Ask Blueprints to refine the architecture, batches, or scope — the plan updates live.</p>
|
| 1775 |
+
) : (
|
| 1776 |
+
messages.map((m) => (
|
| 1777 |
+
<div className={`bd-msg ${m.role}`} key={m.id}>
|
| 1778 |
+
<span className="bd-msg-av">{m.role === "user" ? "M" : <MatrixIcon size={14}>{icons.cube}</MatrixIcon>}</span>
|
| 1779 |
+
<div className="bd-msg-body">
|
| 1780 |
+
<div className="bd-msg-head"><span className="bd-msg-role">{m.role === "user" ? "You" : "Matrix Builder"}</span>{m.time && <span className="bd-msg-time">{m.time}</span>}</div>
|
| 1781 |
+
<div className="bd-msg-text">{m.content}</div>
|
| 1782 |
+
</div>
|
| 1783 |
+
</div>
|
| 1784 |
+
))
|
| 1785 |
+
)
|
| 1786 |
+
) : (
|
| 1787 |
+
<ul className="bd-activity">
|
| 1788 |
+
{activity.map((a, i) => (
|
| 1789 |
+
<li className="bd-activity-row" key={`${i}-${a}`}><span className="bd-activity-dot" />{a}</li>
|
| 1790 |
+
))}
|
| 1791 |
+
</ul>
|
| 1792 |
+
)}
|
| 1793 |
+
{busy && chatTab === "chat" && (
|
| 1794 |
+
<div className="bd-msg blueprint"><span className="bd-msg-av"><MatrixIcon size={14}>{icons.cube}</MatrixIcon></span>
|
| 1795 |
+
<div className="bd-msg-body"><div className="bd-msg-head"><span className="bd-msg-role">Matrix Builder</span></div><div className="bd-msg-text bd-typing">thinking…</div></div></div>
|
| 1796 |
+
)}
|
| 1797 |
+
</div>
|
| 1798 |
+
|
| 1799 |
+
<div className="bd-composer">
|
| 1800 |
+
<button className="bd-composer-add" type="button" aria-label="Attach" title="Attach"><MatrixIcon size={16}>{icons.plus}</MatrixIcon></button>
|
| 1801 |
+
<input className="bd-composer-input" value={draft} placeholder="Describe the change you want…" disabled={busy}
|
| 1802 |
+
onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") send(); }} aria-label="Describe the change you want" />
|
| 1803 |
+
<span className="bd-kbd" aria-hidden="true">⌘↵</span>
|
| 1804 |
+
<button className="bd-composer-send" type="button" onClick={send} aria-label="Send" disabled={busy || !draft.trim()}>
|
| 1805 |
+
<MatrixIcon size={16}>{icons.send}</MatrixIcon>
|
| 1806 |
+
</button>
|
| 1807 |
+
</div>
|
| 1808 |
+
</section>
|
| 1809 |
+
</div>
|
| 1810 |
+
|
| 1811 |
+
{/* RIGHT — sticky sidebar: architecture, file plan, matrix rules, design brain, actions */}
|
| 1812 |
+
<aside className="bd-col bd-aside">
|
| 1813 |
+
<section className="bd-card reveal">
|
| 1814 |
+
<div className="bd-card-h"><MatrixIcon size={16}>{icons.git}</MatrixIcon><h3>Architecture</h3></div>
|
| 1815 |
+
<div className="bd-arch">
|
| 1816 |
+
{architecture.map((n) => (
|
| 1817 |
+
<div className="bd-arch-node" key={n.name}>
|
| 1818 |
+
<span className="bd-arch-top"><span className="bd-arch-ic"><MatrixIcon size={15}>{archIcon(n.name)}</MatrixIcon></span><span className="bd-arch-label">{n.name}</span></span>
|
| 1819 |
+
<span className="bd-arch-sub">{n.description}</span>
|
| 1820 |
+
</div>
|
| 1821 |
+
))}
|
| 1822 |
+
</div>
|
| 1823 |
+
<button className="bd-card-link" type="button" onClick={() => onNotice("Architecture detail view coming soon.")}>View details of Architecture <MatrixIcon size={13}>{icons.arrow}</MatrixIcon></button>
|
| 1824 |
+
</section>
|
| 1825 |
+
|
| 1826 |
+
<section className="bd-card reveal">
|
| 1827 |
+
<div className="bd-card-h"><MatrixIcon size={16}>{icons.doc}</MatrixIcon><h3>File plan</h3></div>
|
| 1828 |
+
<ul className="bd-files">
|
| 1829 |
+
{filePlan.map((f) => (
|
| 1830 |
+
<li className="bd-file" key={f.path}><code className="bd-file-path">{f.path}</code><span className="bd-file-why">{f.description}</span></li>
|
| 1831 |
+
))}
|
| 1832 |
+
</ul>
|
| 1833 |
+
</section>
|
| 1834 |
+
|
| 1835 |
+
<section className="bd-card reveal">
|
| 1836 |
+
<div className="bd-card-h"><MatrixIcon size={16}>{icons.shield}</MatrixIcon><h3>Matrix rules</h3></div>
|
| 1837 |
+
<ul className="bd-rules">
|
| 1838 |
+
{data.matrix_rules.map((rule) => (
|
| 1839 |
+
<li key={rule}><MatrixIcon size={14}>{icons.check}</MatrixIcon> {rule.replace(/^RMD-\d+:\s*/, "")}</li>
|
| 1840 |
+
))}
|
| 1841 |
+
</ul>
|
| 1842 |
+
</section>
|
| 1843 |
+
|
| 1844 |
+
{designerOn && (
|
| 1845 |
+
<section className="bd-card reveal bd-brain">
|
| 1846 |
+
<div className="bd-card-h"><span className="bd-brain-ic">🧠</span><h3>Design Brain</h3>
|
| 1847 |
+
<span className="bd-card-sub">Matrix Designer</span></div>
|
| 1848 |
+
<span className="bd-brain-k">Product intent</span>
|
| 1849 |
+
<p className="bd-brain-intent">{subject} — designed before batching so every batch fits the whole.</p>
|
| 1850 |
+
{(data.risks.length > 0 || data.assumptions.length > 0) && (
|
| 1851 |
+
<p className="bd-brain-note">{[...data.risks, ...data.assumptions].slice(0, 2).join(" · ")}</p>
|
| 1852 |
+
)}
|
| 1853 |
+
<button className="bd-card-link" type="button" onClick={() => setChatTab("activity")}>View details <MatrixIcon size={13}>{icons.arrow}</MatrixIcon></button>
|
| 1854 |
+
</section>
|
| 1855 |
+
)}
|
| 1856 |
+
|
| 1857 |
+
<div className="bd-actions">
|
| 1858 |
+
<button className="bd-back" type="button" onClick={onBack}><MatrixIcon size={15}>{icons.back}</MatrixIcon> Back to blueprints</button>
|
| 1859 |
+
<button className="bd-choose" type="button" onClick={onChoose}>Choose this blueprint <MatrixIcon size={16}>{icons.arrow}</MatrixIcon></button>
|
| 1860 |
+
</div>
|
| 1861 |
+
</aside>
|
| 1862 |
+
</div>
|
| 1863 |
+
</div>
|
| 1864 |
+
</div>
|
| 1865 |
+
);
|
| 1866 |
+
}
|
| 1867 |
+
|
| 1868 |
export default function MatrixBuilderClient({ initialBuild }: { initialBuild?: InitialBuild } = {}) {
|
| 1869 |
// When reopened from My Builds we land directly on the active build screen, reconstructed
|
| 1870 |
// from the persisted build, so the user keeps full context (no state is lost).
|
| 1871 |
const [phase, setPhase] = useState<Phase>(initialBuild ? "bundle" : "hero");
|
| 1872 |
const [idea, setIdea] = useState(initialBuild?.idea ?? "");
|
| 1873 |
const [scanIndex, setScanIndex] = useState(0);
|
| 1874 |
+
// Blueprint Details view — the candidate being inspected, and whether the Matrix Designer
|
| 1875 |
+
// enhancement is on (read from settings when the user opens Details, so it reflects the toggle).
|
| 1876 |
+
const [detailsCandidate, setDetailsCandidate] = useState<BlueprintCandidate | null>(null);
|
| 1877 |
+
const [designerOn, setDesignerOn] = useState(false);
|
| 1878 |
const [candidates, setCandidates] = useState<BlueprintCandidate[]>([]);
|
| 1879 |
const [candidatesLoaded, setCandidatesLoaded] = useState(false);
|
| 1880 |
// Optional Internal AI: display-only copy overrides keyed by candidate id; the deterministic
|
|
|
|
| 1974 |
}
|
| 1975 |
setCandidates(list);
|
| 1976 |
setCandidatesLoaded(true);
|
| 1977 |
+
// Matrix Designer toggle ON: source the 3 cards from the in-browser engine (no network).
|
| 1978 |
+
// The server is an optional enhancement; the client engine is the primary, instant path.
|
| 1979 |
+
if (isMatrixDesignerEnabled()) {
|
| 1980 |
+
setCandidates(generateCandidates(ideaText).map(designerToUiCandidate));
|
| 1981 |
+
}
|
| 1982 |
// Optional Internal AI assist (fail-open): improve display copy only, after candidates show.
|
| 1983 |
void enrichBlueprintCandidates(
|
| 1984 |
ideaText,
|
|
|
|
| 2002 |
}, [phase, scanIndex, candidatesLoaded]);
|
| 2003 |
|
| 2004 |
// Step 2 — generate the chosen candidate's Matrix Bundle on the engine (the real file manifest).
|
| 2005 |
+
// Open the premium Details view for a candidate (read the Designer toggle fresh each time).
|
| 2006 |
+
const openDetails = (candidate: BlueprintCandidate) => {
|
| 2007 |
+
setDetailsCandidate(candidate);
|
| 2008 |
+
setDesignerOn(isMatrixDesignerEnabled());
|
| 2009 |
+
setPhase("details");
|
| 2010 |
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
| 2011 |
+
};
|
| 2012 |
+
|
| 2013 |
const choose = (candidate: BlueprintCandidate) => {
|
| 2014 |
const localId = generateBundleId();
|
| 2015 |
setChosen(candidate);
|
|
|
|
| 2396 |
</details>
|
| 2397 |
)}
|
| 2398 |
<div className="cand-grid stag">
|
| 2399 |
+
{candidates.map((candidate) => <CandidateCard key={candidate.id} candidate={candidate} choose={choose} details={openDetails} enrichment={enrichedById[candidate.id]} />)}
|
| 2400 |
</div>
|
| 2401 |
</div>
|
| 2402 |
</>
|
| 2403 |
)}
|
| 2404 |
|
| 2405 |
+
{phase === "details" && detailsCandidate && (
|
| 2406 |
+
<>
|
| 2407 |
+
<BuilderBar onNew={reset} onNotice={showToast} />
|
| 2408 |
+
<BlueprintDetails
|
| 2409 |
+
candidate={detailsCandidate}
|
| 2410 |
+
subject={headingSubject}
|
| 2411 |
+
idea={effectiveIdea}
|
| 2412 |
+
designerOn={designerOn}
|
| 2413 |
+
aiAssist={isOllaBridgeAssistAvailable()}
|
| 2414 |
+
onBack={() => setPhase("candidates")}
|
| 2415 |
+
onChoose={() => choose(detailsCandidate)}
|
| 2416 |
+
onNotice={showToast}
|
| 2417 |
+
/>
|
| 2418 |
+
</>
|
| 2419 |
+
)}
|
| 2420 |
+
|
| 2421 |
{phase === "bundle" && chosen && (
|
| 2422 |
<BundleResult
|
| 2423 |
candidate={chosen}
|
web/src/components/settings/AiConfigurationSection.tsx
CHANGED
|
@@ -57,6 +57,16 @@ export default function AiConfigurationSection() {
|
|
| 57 |
setDefaultCoder(next);
|
| 58 |
}
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
const ob = settings.ollabridge;
|
| 61 |
const localhost = isLocalhostLike(ob.baseUrl);
|
| 62 |
|
|
@@ -188,6 +198,28 @@ export default function AiConfigurationSection() {
|
|
| 188 |
<p className="ai-help">AI assist is off. Matrix Builder will run deterministically.</p>
|
| 189 |
)}
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
{settings.provider === "ollabridge" && (
|
| 192 |
<>
|
| 193 |
{/* Assisted-mode toggle */}
|
|
|
|
| 57 |
setDefaultCoder(next);
|
| 58 |
}
|
| 59 |
|
| 60 |
+
// Optional Matrix Designer enhancement — persist immediately, like the default-coder preference.
|
| 61 |
+
function onToggleMatrixDesigner(enabled: boolean) {
|
| 62 |
+
setSettings((s) => {
|
| 63 |
+
const next = { ...s, matrixDesigner: { ...s.matrixDesigner, enabled } };
|
| 64 |
+
saveAISettings(next);
|
| 65 |
+
return next;
|
| 66 |
+
});
|
| 67 |
+
setSaveNote(null);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
const ob = settings.ollabridge;
|
| 71 |
const localhost = isLocalhostLike(ob.baseUrl);
|
| 72 |
|
|
|
|
| 198 |
<p className="ai-help">AI assist is off. Matrix Builder will run deterministically.</p>
|
| 199 |
)}
|
| 200 |
|
| 201 |
+
{/* Optional enhancement: Matrix Designer — the design brain. Default OFF. */}
|
| 202 |
+
<div className="ai-divider" />
|
| 203 |
+
<div className="settings-field-group">
|
| 204 |
+
<label className="ai-toggle md-toggle">
|
| 205 |
+
<input
|
| 206 |
+
type="checkbox"
|
| 207 |
+
checked={settings.matrixDesigner.enabled}
|
| 208 |
+
onChange={(e) => onToggleMatrixDesigner(e.target.checked)}
|
| 209 |
+
/>
|
| 210 |
+
<span>
|
| 211 |
+
🧠 Matrix Designer <span className="md-badge">optional</span>
|
| 212 |
+
<em className="ai-toggle-hint">
|
| 213 |
+
{settings.matrixDesigner.enabled
|
| 214 |
+
? settings.provider === "ollabridge" && settings.mode === "assisted"
|
| 215 |
+
? "On — uses your Internal AI (OllaBridge) to design the full batch plan and coder prompts for the next stages. The Matrix contract is unchanged."
|
| 216 |
+
: "On — but it stays deterministic until Internal AI (OllaBridge) assist above is enabled; then it designs the batch plan with AI."
|
| 217 |
+
: "Off — the design brain that plans every batch before you build. Turn on to inspect a full plan on each blueprint’s Details page."}
|
| 218 |
+
</em>
|
| 219 |
+
</span>
|
| 220 |
+
</label>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
{settings.provider === "ollabridge" && (
|
| 224 |
<>
|
| 225 |
{/* Assisted-mode toggle */}
|
web/src/lib/__tests__/blueprint-engine.test.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// C1 — the in-browser blueprint engine is pure and deterministic (no network).
|
| 2 |
+
//
|
| 3 |
+
// Run with: npm run test. These cover the local orchestrator the workspace uses on every chat
|
| 4 |
+
// instruction, so the Details page mutates instantly and works fully offline.
|
| 5 |
+
|
| 6 |
+
import assert from "node:assert/strict";
|
| 7 |
+
import { describe, it } from "node:test";
|
| 8 |
+
|
| 9 |
+
import { apply, generate, generateCandidates } from "@/lib/blueprint-engine";
|
| 10 |
+
|
| 11 |
+
describe("blueprint-engine.generate", () => {
|
| 12 |
+
it("builds a game blueprint for a Phaser idea", () => {
|
| 13 |
+
const d = generate("standard", "an 8-episode Phaser platformer on GitHub Pages");
|
| 14 |
+
assert.equal(d.candidate_id, "standard");
|
| 15 |
+
assert.ok(d.batches.length >= 6);
|
| 16 |
+
assert.ok(d.architecture.some((n) => /phaser/i.test(n.name)));
|
| 17 |
+
assert.ok(d.batches.every((b) => b.allowed_files.length && b.acceptance_criteria.length));
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
it("ramps minimal < standard < production", () => {
|
| 21 |
+
const idea = "A SaaS dashboard with auth";
|
| 22 |
+
const n = (id: string) => generate(id, idea).batches.length;
|
| 23 |
+
assert.ok(n("minimal") < n("standard"));
|
| 24 |
+
assert.ok(n("standard") < n("production"));
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
it("generateCandidates returns the three tiers", () => {
|
| 28 |
+
const cs = generateCandidates("a platformer game");
|
| 29 |
+
assert.deepEqual(cs.map((c) => c.id), ["minimal", "standard", "production"]);
|
| 30 |
+
assert.ok(cs.some((c) => c.recommended));
|
| 31 |
+
});
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
describe("blueprint-engine.apply (local orchestrator)", () => {
|
| 35 |
+
const base = () => generate("standard", "an 8-episode Phaser platformer");
|
| 36 |
+
|
| 37 |
+
it("adds a boss batch", () => {
|
| 38 |
+
const before = base();
|
| 39 |
+
const res = apply(before, "add a boss level");
|
| 40 |
+
assert.equal(res.data.batches.length, before.batches.length + 1);
|
| 41 |
+
assert.match(res.data.batches.at(-1)!.name, /boss/i);
|
| 42 |
+
assert.ok(res.updatedSections.includes("batches"));
|
| 43 |
+
// purity: the input is untouched
|
| 44 |
+
assert.equal(before.batches.length, base().batches.length);
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it("reduces scope", () => {
|
| 48 |
+
const before = base();
|
| 49 |
+
const res = apply(before, "make this smaller");
|
| 50 |
+
assert.equal(res.data.batches.length, before.batches.length - 1);
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
it("splits a batch into two", () => {
|
| 54 |
+
const before = base();
|
| 55 |
+
const res = apply(before, "split batch 3 into two");
|
| 56 |
+
assert.equal(res.data.batches.length, before.batches.length + 1);
|
| 57 |
+
assert.ok(res.data.batches.some((b) => /part 1/.test(b.name)));
|
| 58 |
+
assert.ok(res.data.batches.some((b) => /part 2/.test(b.name)));
|
| 59 |
+
// batches stay renumbered batch-01..NN
|
| 60 |
+
res.data.batches.forEach((b, i) => assert.equal(b.id, `batch-${String(i + 1).padStart(2, "0")}`));
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
it("switches the stack (architecture + file plan)", () => {
|
| 64 |
+
const res = apply(generate("standard", "a web app"), "use FastAPI instead of Express");
|
| 65 |
+
assert.ok(res.updatedSections.includes("architecture"));
|
| 66 |
+
assert.ok(res.updatedSections.includes("filePlan"));
|
| 67 |
+
assert.ok(res.data.architecture.some((n) => /fastapi/i.test(n.description)));
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
it("adds an architecture component (worker/queue)", () => {
|
| 71 |
+
const res = apply(generate("minimal", "a web app"), "add a worker queue");
|
| 72 |
+
assert.ok(res.data.architecture.some((n) => /worker/i.test(n.name)));
|
| 73 |
+
assert.ok(res.updatedSections.includes("architecture"));
|
| 74 |
+
});
|
| 75 |
+
});
|
web/src/lib/__tests__/blueprint-persistence.test.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// C4 — localStorage-first persistence round-trips the workspace; C3 — AI refine is opt-in.
|
| 2 |
+
//
|
| 3 |
+
// Run with: npm run test. These prove a reload restores the workspace with no server, and that
|
| 4 |
+
// the optional AI enhancement stays off (deterministic, no network) unless explicitly enabled.
|
| 5 |
+
|
| 6 |
+
import assert from "node:assert/strict";
|
| 7 |
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
| 8 |
+
|
| 9 |
+
import { generate } from "@/lib/blueprint-engine";
|
| 10 |
+
import { clearWorkspace, loadWorkspace, saveWorkspace, workspaceKey } from "@/lib/blueprint-persistence";
|
| 11 |
+
import { refineWithAI } from "@/lib/ai-refine";
|
| 12 |
+
|
| 13 |
+
function installFakeWindow(): Map<string, string> {
|
| 14 |
+
const store = new Map<string, string>();
|
| 15 |
+
(globalThis as Record<string, unknown>).window = {
|
| 16 |
+
localStorage: {
|
| 17 |
+
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
| 18 |
+
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
| 19 |
+
removeItem: (k: string) => void store.delete(k),
|
| 20 |
+
},
|
| 21 |
+
};
|
| 22 |
+
return store;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
beforeEach(() => installFakeWindow());
|
| 26 |
+
afterEach(() => { delete (globalThis as Record<string, unknown>).window; });
|
| 27 |
+
|
| 28 |
+
describe("C4 persistence", () => {
|
| 29 |
+
it("save → load restores the workspace (reload with no server)", () => {
|
| 30 |
+
const idea = "an 8-episode Phaser platformer";
|
| 31 |
+
const key = workspaceKey(idea, "standard");
|
| 32 |
+
const data = generate("standard", idea);
|
| 33 |
+
data.chat_history = [{ id: "u1", role: "user", content: "add a boss level", timestamp: "" }];
|
| 34 |
+
saveWorkspace(key, data);
|
| 35 |
+
|
| 36 |
+
const restored = loadWorkspace(key);
|
| 37 |
+
assert.ok(restored);
|
| 38 |
+
assert.equal(restored!.candidate_id, "standard");
|
| 39 |
+
assert.equal(restored!.batches.length, data.batches.length);
|
| 40 |
+
assert.equal(restored!.chat_history[0].content, "add a boss level");
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
it("keys are stable per idea + candidate and isolated across them", () => {
|
| 44 |
+
assert.equal(workspaceKey("idea A", "standard"), workspaceKey("idea A", "standard"));
|
| 45 |
+
assert.notEqual(workspaceKey("idea A", "standard"), workspaceKey("idea B", "standard"));
|
| 46 |
+
assert.notEqual(workspaceKey("idea A", "standard"), workspaceKey("idea A", "minimal"));
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
it("clear removes the saved workspace", () => {
|
| 50 |
+
const key = workspaceKey("x", "standard");
|
| 51 |
+
saveWorkspace(key, generate("standard", "x"));
|
| 52 |
+
clearWorkspace(key);
|
| 53 |
+
assert.equal(loadWorkspace(key), null);
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
it("missing / corrupt entries load as null (never throw)", () => {
|
| 57 |
+
assert.equal(loadWorkspace(workspaceKey("never-saved", "standard")), null);
|
| 58 |
+
const ls = (globalThis as unknown as { window: { localStorage: Storage } }).window.localStorage;
|
| 59 |
+
ls.setItem(workspaceKey("bad", "standard"), "{not json");
|
| 60 |
+
assert.equal(loadWorkspace(workspaceKey("bad", "standard")), null);
|
| 61 |
+
});
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
describe("C3 AI refine (optional, fail-open)", () => {
|
| 65 |
+
it("returns null when assist is unavailable — deterministic result stands", async () => {
|
| 66 |
+
// No OllaBridge settings / no signed-in user in the test env → assist is off.
|
| 67 |
+
const out = await refineWithAI(generate("standard", "a web app"), "add audit logging");
|
| 68 |
+
assert.equal(out, null);
|
| 69 |
+
});
|
| 70 |
+
});
|
web/src/lib/__tests__/offline-workspace.test.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// C6 — Proof: the Blueprint workspace is fully offline.
|
| 2 |
+
//
|
| 3 |
+
// With the network blocked, generate → edit → persist → reload all succeed and the data path
|
| 4 |
+
// makes ZERO requests. The capability probe degrades to {server:false} without throwing, so no
|
| 5 |
+
// per-interaction request is ever attempted. This is the unit-level equivalent of an
|
| 6 |
+
// airplane-mode browser e2e (the app has no Playwright harness).
|
| 7 |
+
|
| 8 |
+
import assert from "node:assert/strict";
|
| 9 |
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
| 10 |
+
|
| 11 |
+
import { apply, generate } from "@/lib/blueprint-engine";
|
| 12 |
+
import { loadWorkspace, saveWorkspace, workspaceKey } from "@/lib/blueprint-persistence";
|
| 13 |
+
import { getCapabilities, resetCapabilities } from "@/lib/capabilities";
|
| 14 |
+
|
| 15 |
+
let fetchCalls = 0;
|
| 16 |
+
const realFetch = globalThis.fetch;
|
| 17 |
+
|
| 18 |
+
function installOfflineWindow(): void {
|
| 19 |
+
const store = new Map<string, string>();
|
| 20 |
+
(globalThis as Record<string, unknown>).window = {
|
| 21 |
+
localStorage: {
|
| 22 |
+
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
| 23 |
+
setItem: (k: string, v: string) => void store.set(k, String(v)),
|
| 24 |
+
removeItem: (k: string) => void store.delete(k),
|
| 25 |
+
},
|
| 26 |
+
};
|
| 27 |
+
// Network is blocked: every request rejects (like airplane mode / refused connection).
|
| 28 |
+
(globalThis as Record<string, unknown>).fetch = (..._args: unknown[]) => {
|
| 29 |
+
fetchCalls++;
|
| 30 |
+
return Promise.reject(new Error("ERR_NETWORK_BLOCKED"));
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
beforeEach(() => { fetchCalls = 0; resetCapabilities(); installOfflineWindow(); });
|
| 35 |
+
afterEach(() => {
|
| 36 |
+
delete (globalThis as Record<string, unknown>).window;
|
| 37 |
+
(globalThis as Record<string, unknown>).fetch = realFetch;
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
describe("C6 — fully offline workspace", () => {
|
| 41 |
+
it("generate → edit → persist → reload, with ZERO network on the data path", () => {
|
| 42 |
+
const idea = "an 8-episode Phaser platformer on GitHub Pages";
|
| 43 |
+
const key = workspaceKey(idea, "standard");
|
| 44 |
+
|
| 45 |
+
// generate (local)
|
| 46 |
+
let data = generate("standard", idea);
|
| 47 |
+
const before = data.batches.length;
|
| 48 |
+
|
| 49 |
+
// edit (local engine) — add a boss
|
| 50 |
+
const res = apply(data, "add a boss level");
|
| 51 |
+
data = res.data;
|
| 52 |
+
assert.equal(data.batches.length, before + 1);
|
| 53 |
+
assert.match(data.batches.at(-1)!.name, /boss/i);
|
| 54 |
+
|
| 55 |
+
// persist (localStorage) + reload (new read)
|
| 56 |
+
saveWorkspace(key, data);
|
| 57 |
+
const reloaded = loadWorkspace(key);
|
| 58 |
+
assert.ok(reloaded);
|
| 59 |
+
assert.equal(reloaded!.batches.length, before + 1);
|
| 60 |
+
assert.match(reloaded!.batches.at(-1)!.name, /boss/i);
|
| 61 |
+
|
| 62 |
+
// the entire data path touched the network zero times
|
| 63 |
+
assert.equal(fetchCalls, 0);
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
it("capability probe degrades to {server:false} without throwing", async () => {
|
| 67 |
+
const cap = await getCapabilities({ timeoutMs: 50 });
|
| 68 |
+
assert.equal(cap.server, false);
|
| 69 |
+
assert.equal(fetchCalls, 1); // exactly one probe, caught — not per interaction
|
| 70 |
+
// cached: a second call does not probe again
|
| 71 |
+
const again = await getCapabilities({ timeoutMs: 50 });
|
| 72 |
+
assert.equal(again.server, false);
|
| 73 |
+
assert.equal(fetchCalls, 1);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
it("a second session restores the same workspace from localStorage", () => {
|
| 77 |
+
const idea = "a SaaS dashboard with auth";
|
| 78 |
+
const key = workspaceKey(idea, "production");
|
| 79 |
+
const data = apply(generate("production", idea), "add audit logging").data;
|
| 80 |
+
saveWorkspace(key, data);
|
| 81 |
+
|
| 82 |
+
// "reload": a fresh read of the same key returns the identical plan
|
| 83 |
+
const restored = loadWorkspace(key);
|
| 84 |
+
assert.deepEqual(restored!.batches.map((b) => b.name), data.batches.map((b) => b.name));
|
| 85 |
+
assert.equal(fetchCalls, 0);
|
| 86 |
+
});
|
| 87 |
+
});
|
web/src/lib/ai-refine.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// C3 — Optional AI enhancement for the Blueprint workspace.
|
| 2 |
+
//
|
| 3 |
+
// After the in-browser engine has ALREADY applied a chat instruction (instant, governed), this
|
| 4 |
+
// makes ONE provider-agnostic call (through OllaBridge, which can bridge to any model) to refine
|
| 5 |
+
// only the EXPLANATION — `overview` / `designBrain` prose and a short `reply`. It is:
|
| 6 |
+
// • optional — returns null when assist is off / no user (deterministic result stands)
|
| 7 |
+
// • bounded — a hard timeout; a slow model never blocks the UI
|
| 8 |
+
// • fail-open — any error/parse-failure returns null
|
| 9 |
+
// • safe — it can never change architecture, batches, or files (governance unchanged)
|
| 10 |
+
|
| 11 |
+
import { isOllaBridgeAssistAvailable, sendAIMessage } from "@/lib/ai-provider-manager";
|
| 12 |
+
import type { BlueprintDetailsData } from "@/types/blueprint-state";
|
| 13 |
+
|
| 14 |
+
export interface AIRefinement {
|
| 15 |
+
reply?: string;
|
| 16 |
+
overview?: string;
|
| 17 |
+
designBrain?: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const SYSTEM =
|
| 21 |
+
"You refine the EXPLANATION of a software blueprint after a change was applied. You may improve " +
|
| 22 |
+
"the 'overview' and 'designBrain' prose and write a short 'reply' confirming the change. You MUST " +
|
| 23 |
+
"NOT invent or change architecture, batches, files, or stack. Return ONLY compact JSON of the form " +
|
| 24 |
+
'{"reply":"...","overview":"...","designBrain":"..."} with no extra text.';
|
| 25 |
+
|
| 26 |
+
export async function refineWithAI(
|
| 27 |
+
data: BlueprintDetailsData,
|
| 28 |
+
instruction: string,
|
| 29 |
+
opts: { timeoutMs?: number } = {},
|
| 30 |
+
): Promise<AIRefinement | null> {
|
| 31 |
+
if (!isOllaBridgeAssistAvailable()) return null;
|
| 32 |
+
const timeoutMs = opts.timeoutMs ?? 6000;
|
| 33 |
+
const user =
|
| 34 |
+
`Instruction just applied: ${instruction}\n` +
|
| 35 |
+
`Current overview: ${data.overview}\n` +
|
| 36 |
+
`Batches: ${data.batches.map((b) => b.name).join(", ")}\n` +
|
| 37 |
+
`Architecture: ${data.architecture.map((n) => n.name).join(", ")}`;
|
| 38 |
+
try {
|
| 39 |
+
const text = await withTimeout(
|
| 40 |
+
sendAIMessage([
|
| 41 |
+
{ role: "system", content: SYSTEM },
|
| 42 |
+
{ role: "user", content: user },
|
| 43 |
+
]),
|
| 44 |
+
timeoutMs,
|
| 45 |
+
);
|
| 46 |
+
const json = extractJson(text);
|
| 47 |
+
if (!json) return null;
|
| 48 |
+
const out: AIRefinement = {};
|
| 49 |
+
if (typeof json.reply === "string" && json.reply.trim()) out.reply = json.reply.trim();
|
| 50 |
+
if (typeof json.overview === "string" && json.overview.trim()) out.overview = json.overview.trim();
|
| 51 |
+
if (typeof json.designBrain === "string" && json.designBrain.trim()) out.designBrain = json.designBrain.trim();
|
| 52 |
+
return Object.keys(out).length ? out : null;
|
| 53 |
+
} catch {
|
| 54 |
+
return null; // fail-open: the deterministic local result stands
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
|
| 59 |
+
return new Promise<T>((resolve, reject) => {
|
| 60 |
+
const t = setTimeout(() => reject(new Error("ai-refine timeout")), ms);
|
| 61 |
+
p.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
|
| 62 |
+
});
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function extractJson(text: string): Record<string, unknown> | null {
|
| 66 |
+
const m = text.match(/\{[\s\S]*\}/);
|
| 67 |
+
if (!m) return null;
|
| 68 |
+
try {
|
| 69 |
+
return JSON.parse(m[0]) as Record<string, unknown>;
|
| 70 |
+
} catch {
|
| 71 |
+
return null;
|
| 72 |
+
}
|
| 73 |
+
}
|
web/src/lib/ai-settings-store.ts
CHANGED
|
@@ -22,6 +22,7 @@ export function mergeAISettings(raw: unknown): MatrixAISettings {
|
|
| 22 |
const mode = r.mode === "assisted" ? "assisted" : "deterministic";
|
| 23 |
const authMode =
|
| 24 |
ob.authMode === "api_key" || ob.authMode === "local-trust" ? ob.authMode : "pairing";
|
|
|
|
| 25 |
return {
|
| 26 |
provider,
|
| 27 |
mode,
|
|
@@ -33,9 +34,17 @@ export function mergeAISettings(raw: unknown): MatrixAISettings {
|
|
| 33 |
pairToken: typeof ob.pairToken === "string" ? ob.pairToken : "",
|
| 34 |
deviceId: typeof ob.deviceId === "string" ? ob.deviceId : "",
|
| 35 |
},
|
|
|
|
|
|
|
|
|
|
| 36 |
};
|
| 37 |
}
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
export function getAISettings(): MatrixAISettings {
|
| 40 |
if (typeof window === "undefined") return { ...DEFAULT_AI_SETTINGS };
|
| 41 |
const raw = window.localStorage.getItem(AI_SETTINGS_STORAGE_KEY);
|
|
|
|
| 22 |
const mode = r.mode === "assisted" ? "assisted" : "deterministic";
|
| 23 |
const authMode =
|
| 24 |
ob.authMode === "api_key" || ob.authMode === "local-trust" ? ob.authMode : "pairing";
|
| 25 |
+
const md = (r.matrixDesigner ?? {}) as Record<string, unknown>;
|
| 26 |
return {
|
| 27 |
provider,
|
| 28 |
mode,
|
|
|
|
| 34 |
pairToken: typeof ob.pairToken === "string" ? ob.pairToken : "",
|
| 35 |
deviceId: typeof ob.deviceId === "string" ? ob.deviceId : "",
|
| 36 |
},
|
| 37 |
+
matrixDesigner: {
|
| 38 |
+
enabled: md.enabled === true,
|
| 39 |
+
},
|
| 40 |
};
|
| 41 |
}
|
| 42 |
|
| 43 |
+
// Convenience: is the optional Matrix Designer enhancement turned on?
|
| 44 |
+
export function isMatrixDesignerEnabled(): boolean {
|
| 45 |
+
return getAISettings().matrixDesigner.enabled;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
export function getAISettings(): MatrixAISettings {
|
| 49 |
if (typeof window === "undefined") return { ...DEFAULT_AI_SETTINGS };
|
| 50 |
const raw = window.localStorage.getItem(AI_SETTINGS_STORAGE_KEY);
|
web/src/lib/blueprint-client.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Optional server bridge for the Blueprint Details workspace.
|
| 2 |
+
//
|
| 3 |
+
// The workspace is driven entirely by the in-browser engine + store (C0/C1/C2) — these fetch
|
| 4 |
+
// wrappers are the OPTIONAL enhancement path: when a control plane is configured they sync /
|
| 5 |
+
// fetch real designer output, and on any failure they fall back to the local engine. Nothing on
|
| 6 |
+
// the render path depends on them.
|
| 7 |
+
|
| 8 |
+
import { apiBaseUrl } from "./api-client";
|
| 9 |
+
import { authHeaders } from "./auth-token";
|
| 10 |
+
import * as engine from "@/lib/blueprint-engine";
|
| 11 |
+
|
| 12 |
+
export type {
|
| 13 |
+
ArchitectureNode,
|
| 14 |
+
BlueprintDetailsData,
|
| 15 |
+
DesignerBatch,
|
| 16 |
+
DesignerCandidate,
|
| 17 |
+
DesignerChatMessage,
|
| 18 |
+
FilePlanItem,
|
| 19 |
+
} from "@/types/blueprint-state";
|
| 20 |
+
|
| 21 |
+
import type { BlueprintDetailsData, DesignerCandidate } from "@/types/blueprint-state";
|
| 22 |
+
|
| 23 |
+
// Local engine derivations re-exported for back-compat with existing callers.
|
| 24 |
+
export const deriveLocalDetails = engine.generate;
|
| 25 |
+
export const deriveLocalCandidates = engine.generateCandidates;
|
| 26 |
+
|
| 27 |
+
/** The 3 blueprint cards. `useDesigner=false` (toggle off) / offline → local engine. */
|
| 28 |
+
export async function fetchDesignerCandidates(
|
| 29 |
+
idea: string,
|
| 30 |
+
opts: { useDesigner?: boolean } = {},
|
| 31 |
+
): Promise<DesignerCandidate[]> {
|
| 32 |
+
if (opts.useDesigner === false) return engine.generateCandidates(idea);
|
| 33 |
+
try {
|
| 34 |
+
const url = `${apiBaseUrl}/api/v1/blueprints/designer-candidates?idea=${encodeURIComponent(idea)}`;
|
| 35 |
+
const res = await fetch(url, { headers: { ...authHeaders() } });
|
| 36 |
+
if (!res.ok) throw new Error(`designer-candidates ${res.status}`);
|
| 37 |
+
const body = (await res.json()) as { candidates?: DesignerCandidate[] };
|
| 38 |
+
return body.candidates && body.candidates.length ? body.candidates : engine.generateCandidates(idea);
|
| 39 |
+
} catch {
|
| 40 |
+
return engine.generateCandidates(idea);
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/** Optional: fetch real designer Details. Offline / toggle off → local engine. */
|
| 45 |
+
export async function fetchBlueprintDetails(
|
| 46 |
+
candidateId: string,
|
| 47 |
+
idea: string,
|
| 48 |
+
opts: { useDesigner?: boolean } = {},
|
| 49 |
+
): Promise<BlueprintDetailsData> {
|
| 50 |
+
if (opts.useDesigner === false) return engine.generate(candidateId, idea);
|
| 51 |
+
try {
|
| 52 |
+
const url = `${apiBaseUrl}/api/v1/blueprints/${candidateId}/details?idea=${encodeURIComponent(idea)}`;
|
| 53 |
+
const res = await fetch(url, { headers: { ...authHeaders() } });
|
| 54 |
+
if (!res.ok) throw new Error(`details ${res.status}`);
|
| 55 |
+
return (await res.json()) as BlueprintDetailsData;
|
| 56 |
+
} catch {
|
| 57 |
+
return engine.generate(candidateId, idea);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/** Optional server refinement; the workspace already applied the local engine result. */
|
| 62 |
+
export async function sendBlueprintChat(
|
| 63 |
+
candidateId: string,
|
| 64 |
+
idea: string,
|
| 65 |
+
message: string,
|
| 66 |
+
): Promise<{ reply: string; details: BlueprintDetailsData }> {
|
| 67 |
+
try {
|
| 68 |
+
const url = `${apiBaseUrl}/api/v1/blueprints/${candidateId}/chat`;
|
| 69 |
+
const res = await fetch(url, {
|
| 70 |
+
method: "POST",
|
| 71 |
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
| 72 |
+
body: JSON.stringify({ idea, message }),
|
| 73 |
+
});
|
| 74 |
+
if (!res.ok) throw new Error(`chat ${res.status}`);
|
| 75 |
+
return (await res.json()) as { reply: string; details: BlueprintDetailsData };
|
| 76 |
+
} catch {
|
| 77 |
+
const res = engine.apply(engine.generate(candidateId, idea), message);
|
| 78 |
+
return { reply: res.reply, details: res.data };
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export async function saveBlueprintDetails(
|
| 83 |
+
candidateId: string,
|
| 84 |
+
idea: string,
|
| 85 |
+
details: BlueprintDetailsData,
|
| 86 |
+
buildId?: string,
|
| 87 |
+
): Promise<BlueprintDetailsData> {
|
| 88 |
+
try {
|
| 89 |
+
const url = `${apiBaseUrl}/api/v1/blueprints/${candidateId}/save`;
|
| 90 |
+
const res = await fetch(url, {
|
| 91 |
+
method: "POST",
|
| 92 |
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
| 93 |
+
body: JSON.stringify({ idea, build_id: buildId, details }),
|
| 94 |
+
});
|
| 95 |
+
if (!res.ok) throw new Error(`save ${res.status}`);
|
| 96 |
+
return (await res.json()) as BlueprintDetailsData;
|
| 97 |
+
} catch {
|
| 98 |
+
return details; // offline: kept locally (localStorage persistence is C4)
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/** Restore a previously-saved blueprint + chat for a build (owner-scoped). */
|
| 103 |
+
export async function fetchSavedBlueprint(
|
| 104 |
+
candidateId: string,
|
| 105 |
+
buildId: string,
|
| 106 |
+
): Promise<BlueprintDetailsData | null> {
|
| 107 |
+
try {
|
| 108 |
+
const url = `${apiBaseUrl}/api/v1/blueprints/${candidateId}/saved?build_id=${encodeURIComponent(buildId)}`;
|
| 109 |
+
const res = await fetch(url, { headers: { ...authHeaders() } });
|
| 110 |
+
if (!res.ok) throw new Error(`saved ${res.status}`);
|
| 111 |
+
const body = (await res.json()) as { found: boolean; details: BlueprintDetailsData | null };
|
| 112 |
+
return body.found ? body.details : null;
|
| 113 |
+
} catch {
|
| 114 |
+
return null;
|
| 115 |
+
}
|
| 116 |
+
}
|
web/src/lib/blueprint-engine.ts
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// C1 — Browser blueprint-engine: deterministic, pure, no network.
|
| 2 |
+
//
|
| 3 |
+
// `generate(idea)` builds the initial blueprint; `apply(state, instruction)` is the local
|
| 4 |
+
// orchestrator that mutates the plan from a chat instruction (add / reduce / split batches,
|
| 5 |
+
// switch stack, add components) and returns the patched state + a reply + which sections
|
| 6 |
+
// changed. This is the primary path — the LLM (C3) only *refines* this result, optionally.
|
| 7 |
+
|
| 8 |
+
import {
|
| 9 |
+
type ArchitectureNode,
|
| 10 |
+
type BlueprintDetailsData,
|
| 11 |
+
type BlueprintSection,
|
| 12 |
+
type DesignerBatch,
|
| 13 |
+
type DesignerCandidate,
|
| 14 |
+
type FilePlanItem,
|
| 15 |
+
RMD_RULES,
|
| 16 |
+
} from "@/types/blueprint-state";
|
| 17 |
+
|
| 18 |
+
type Domain = "web-game" | "api" | "web-app";
|
| 19 |
+
type Tier = { id: string; difficulty: string; scale: number };
|
| 20 |
+
|
| 21 |
+
const TIERS: Tier[] = [
|
| 22 |
+
{ id: "minimal", difficulty: "Easy", scale: 0.55 },
|
| 23 |
+
{ id: "standard", difficulty: "Medium", scale: 1.0 },
|
| 24 |
+
{ id: "production", difficulty: "Hard", scale: 1.6 },
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
// ---- generate -------------------------------------------------------------------------
|
| 28 |
+
export function generate(candidateId: string, idea: string): BlueprintDetailsData {
|
| 29 |
+
const tier = TIERS.find((t) => t.id === candidateId) ?? TIERS[1];
|
| 30 |
+
const domain = inferDomain(idea);
|
| 31 |
+
const full = roadmap(domain);
|
| 32 |
+
const n = Math.max(3, Math.round(full.length * tier.scale));
|
| 33 |
+
const batches = n <= full.length ? full.slice(0, n) : full.concat(extra(full.length, n - full.length));
|
| 34 |
+
return {
|
| 35 |
+
candidate_id: candidateId,
|
| 36 |
+
overview: `This ${candidateId} blueprint builds ${idea.trim().slice(0, 120)}. Delivered in ordered, validated batches.`,
|
| 37 |
+
architecture: candidateId === "minimal" ? architecture(domain).slice(0, 2) : architecture(domain),
|
| 38 |
+
batches,
|
| 39 |
+
file_plan: filePlan(domain),
|
| 40 |
+
matrix_rules: RMD_RULES,
|
| 41 |
+
acceptance_criteria: ["builds and runs", "core flow works with tests", "Matrix validation approves the build"],
|
| 42 |
+
validation_plan: ["lint", "typecheck", "unit tests", "allowed-file check", "Matrix commit check"],
|
| 43 |
+
risks: ["external credentials may be required"],
|
| 44 |
+
assumptions: [domain === "web-game" ? "original assets only" : "auth required if data is sensitive"],
|
| 45 |
+
design_brain: `Client engine · ${batches.length} batches · ${candidateId}.`,
|
| 46 |
+
chat_history: [],
|
| 47 |
+
};
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export function generateCandidates(idea: string): DesignerCandidate[] {
|
| 51 |
+
const domain = inferDomain(idea);
|
| 52 |
+
const stack = stackFor(domain);
|
| 53 |
+
const meta: [string, string, string, string, boolean][] = [
|
| 54 |
+
["minimal", "Minimal", "Easy", "a weekend", false],
|
| 55 |
+
["standard", "Standard", "Medium", "about one week", true],
|
| 56 |
+
["production", "Production", "Hard", "about three weeks", false],
|
| 57 |
+
];
|
| 58 |
+
return meta.map(([id, tier, difficulty, estimate, recommended]) => ({
|
| 59 |
+
id, tier, difficulty, estimate, recommended, stack,
|
| 60 |
+
title: `${tier} ${id === "standard" ? "Matrix Bundle" : "controlled blueprint"}`,
|
| 61 |
+
summary: `${recommended ? "Recommended " : ""}controlled blueprint for: ${idea.trim().slice(0, 90)}`,
|
| 62 |
+
file_count: generate(id, idea).batches.length * (id === "production" ? 7 : 5),
|
| 63 |
+
}));
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// ---- apply — the local orchestrator ---------------------------------------------------
|
| 67 |
+
export interface ApplyResult {
|
| 68 |
+
data: BlueprintDetailsData;
|
| 69 |
+
reply: string;
|
| 70 |
+
updatedSections: BlueprintSection[];
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export function apply(state: BlueprintDetailsData, instruction: string): ApplyResult {
|
| 74 |
+
const text = instruction.trim();
|
| 75 |
+
const m = text.toLowerCase();
|
| 76 |
+
const data = clone(state);
|
| 77 |
+
const sections = new Set<BlueprintSection>();
|
| 78 |
+
let reply = "Noted. Save latest edits to keep this.";
|
| 79 |
+
|
| 80 |
+
const nextId = () => `batch-${String(data.batches.length + 1).padStart(2, "0")}`;
|
| 81 |
+
|
| 82 |
+
// 1) split a batch into two
|
| 83 |
+
if (/\bsplit\b/.test(m)) {
|
| 84 |
+
const idx = pickBatchIndex(data, m);
|
| 85 |
+
if (idx >= 0) {
|
| 86 |
+
const b = data.batches[idx];
|
| 87 |
+
const partB: DesignerBatch = {
|
| 88 |
+
...clone(b), id: nextId(), name: `${b.name} (part 2)`, depends_on: [b.id],
|
| 89 |
+
};
|
| 90 |
+
data.batches[idx] = { ...b, name: `${b.name} (part 1)` };
|
| 91 |
+
data.batches.splice(idx + 1, 0, partB);
|
| 92 |
+
renumber(data);
|
| 93 |
+
sections.add("batches");
|
| 94 |
+
reply = `Split “${b.name}” into two batches. Review and Save latest edits.`;
|
| 95 |
+
return finish(data, sections, reply);
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// 2) reduce / remove scope
|
| 100 |
+
if (/\b(reduce|smaller|simpler|remove|drop|trim)\b/.test(m)) {
|
| 101 |
+
if (data.batches.length > 3) {
|
| 102 |
+
const dropped = data.batches.pop()!;
|
| 103 |
+
sections.add("batches");
|
| 104 |
+
reply = `Reduced scope — removed “${dropped.name}”.`;
|
| 105 |
+
} else {
|
| 106 |
+
reply = "Already at the minimal batch set.";
|
| 107 |
+
}
|
| 108 |
+
return finish(data, sections, reply);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// 3) switch stack / framework (architecture + file plan)
|
| 112 |
+
const stackHit = detectStack(m);
|
| 113 |
+
if (stackHit) {
|
| 114 |
+
applyStack(data, stackHit);
|
| 115 |
+
sections.add("architecture");
|
| 116 |
+
sections.add("filePlan");
|
| 117 |
+
reply = `Switched to ${stackHit.label}. Updated architecture and file plan.`;
|
| 118 |
+
return finish(data, sections, reply);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// 4) add an architecture component
|
| 122 |
+
const comp = detectComponent(m);
|
| 123 |
+
if (comp) {
|
| 124 |
+
if (!data.architecture.some((n) => n.name === comp.name)) {
|
| 125 |
+
data.architecture.push(comp);
|
| 126 |
+
sections.add("architecture");
|
| 127 |
+
}
|
| 128 |
+
// also add a batch to build it
|
| 129 |
+
data.batches.push(makeBatch(nextId(), comp.batchName, comp.allowed, text));
|
| 130 |
+
renumber(data);
|
| 131 |
+
sections.add("batches");
|
| 132 |
+
reply = `Added ${comp.name} (architecture + a build batch).`;
|
| 133 |
+
return finish(data, sections, reply);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// 5) add a feature / level / boss → a scoped batch
|
| 137 |
+
if (/\b(add|include|introduce|boss|level|enemy|feature|analytic|metric|audit|auth|dashboard|observab)\b/.test(m)) {
|
| 138 |
+
const name = featureName(m);
|
| 139 |
+
data.batches.push(makeBatch(nextId(), name, ["src/**"], text));
|
| 140 |
+
renumber(data);
|
| 141 |
+
sections.add("batches");
|
| 142 |
+
reply = `Added ${data.batches[data.batches.length - 1].id} — ${name}. Review and Save latest edits.`;
|
| 143 |
+
return finish(data, sections, reply);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
return finish(data, sections, reply);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// ---- helpers --------------------------------------------------------------------------
|
| 150 |
+
function finish(data: BlueprintDetailsData, sections: Set<BlueprintSection>, reply: string): ApplyResult {
|
| 151 |
+
if (sections.has("batches")) {
|
| 152 |
+
data.design_brain = `Client engine · ${data.batches.length} batches · ${data.candidate_id}.`;
|
| 153 |
+
sections.add("designBrain");
|
| 154 |
+
}
|
| 155 |
+
return { data, reply, updatedSections: [...sections] };
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function makeBatch(id: string, name: string, allowed: string[], from: string): DesignerBatch {
|
| 159 |
+
return {
|
| 160 |
+
id, name, purpose: `Added from chat: ${from}`, tasks: [from],
|
| 161 |
+
allowed_files: allowed, depends_on: [], acceptance_criteria: ["feature works", "no runtime errors"],
|
| 162 |
+
validation_checks: ["unit tests"], must_not_change: [],
|
| 163 |
+
};
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function renumber(data: BlueprintDetailsData): void {
|
| 167 |
+
data.batches.forEach((b, i) => { b.id = `batch-${String(i + 1).padStart(2, "0")}`; });
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function pickBatchIndex(data: BlueprintDetailsData, m: string): number {
|
| 171 |
+
const num = m.match(/\b(\d{1,2})\b/);
|
| 172 |
+
if (num) {
|
| 173 |
+
const i = parseInt(num[1], 10) - 1;
|
| 174 |
+
if (i >= 0 && i < data.batches.length) return i;
|
| 175 |
+
}
|
| 176 |
+
return data.batches.length - 1;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function featureName(m: string): string {
|
| 180 |
+
if (/boss/.test(m)) return "Boss encounter";
|
| 181 |
+
if (/level/.test(m)) return "Extra level";
|
| 182 |
+
if (/analytic|metric/.test(m)) return "Analytics & metrics";
|
| 183 |
+
if (/audit/.test(m)) return "Audit logging";
|
| 184 |
+
if (/auth/.test(m)) return "Auth & access";
|
| 185 |
+
if (/dashboard/.test(m)) return "Admin dashboard";
|
| 186 |
+
if (/observab/.test(m)) return "Observability";
|
| 187 |
+
return "Refinement";
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function detectStack(m: string): { label: string; nodes: ArchitectureNode[]; files: FilePlanItem[] } | null {
|
| 191 |
+
if (/next\.?js|nextjs/.test(m)) {
|
| 192 |
+
return { label: "Next.js", nodes: [{ name: "Web App", description: "Next.js", dependencies: [] }],
|
| 193 |
+
files: [{ path: "apps/web", description: "Next.js app" }] };
|
| 194 |
+
}
|
| 195 |
+
if (/fastapi/.test(m)) {
|
| 196 |
+
return { label: "FastAPI", nodes: [{ name: "API", description: "FastAPI", dependencies: ["Web App"] }],
|
| 197 |
+
files: [{ path: "services/api", description: "FastAPI service" }] };
|
| 198 |
+
}
|
| 199 |
+
if (/\bexpress\b|\bnode\b/.test(m) && /(use|switch|instead)/.test(m)) {
|
| 200 |
+
return { label: "Express", nodes: [{ name: "API", description: "Express", dependencies: ["Web App"] }],
|
| 201 |
+
files: [{ path: "services/api", description: "Express service" }] };
|
| 202 |
+
}
|
| 203 |
+
return null;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function applyStack(data: BlueprintDetailsData, hit: { nodes: ArchitectureNode[]; files: FilePlanItem[] }): void {
|
| 207 |
+
for (const node of hit.nodes) {
|
| 208 |
+
const i = data.architecture.findIndex((n) => n.name === node.name);
|
| 209 |
+
if (i >= 0) data.architecture[i] = node; else data.architecture.push(node);
|
| 210 |
+
}
|
| 211 |
+
for (const f of hit.files) {
|
| 212 |
+
const i = data.file_plan.findIndex((p) => p.path === f.path);
|
| 213 |
+
if (i >= 0) data.file_plan[i] = f; else data.file_plan.unshift(f);
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function detectComponent(m: string): (ArchitectureNode & { batchName: string; allowed: string[] }) | null {
|
| 218 |
+
if (/worker|queue/.test(m)) {
|
| 219 |
+
return { name: "Worker / Queue", description: "Background jobs", dependencies: ["API"], batchName: "Worker & queue", allowed: ["services/worker/**"] };
|
| 220 |
+
}
|
| 221 |
+
if (/redis|cache/.test(m)) {
|
| 222 |
+
return { name: "Cache (Redis)", description: "Caching layer", dependencies: ["API"], batchName: "Caching layer", allowed: ["services/api/**"] };
|
| 223 |
+
}
|
| 224 |
+
if (/database|postgres|\bdb\b/.test(m) && /(add|use)/.test(m)) {
|
| 225 |
+
return { name: "Database", description: "Persistent storage", dependencies: ["API"], batchName: "Data model & migrations", allowed: ["db/**"] };
|
| 226 |
+
}
|
| 227 |
+
return null;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function clone<T>(v: T): T {
|
| 231 |
+
return JSON.parse(JSON.stringify(v)) as T;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// ---- domain knowledge -----------------------------------------------------------------
|
| 235 |
+
function inferDomain(idea: string): Domain {
|
| 236 |
+
const s = idea.toLowerCase();
|
| 237 |
+
if (/(game|platformer|phaser|arcade|canvas)/.test(s)) return "web-game";
|
| 238 |
+
if (/(api|fastapi|service|endpoint)/.test(s)) return "api";
|
| 239 |
+
return "web-app";
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function stackFor(domain: Domain): string[] {
|
| 243 |
+
return domain === "web-game" ? ["Phaser 3", "TypeScript", "Vite"] : ["Next.js", "FastAPI", "PostgreSQL", "Docker"];
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
function architecture(domain: Domain): ArchitectureNode[] {
|
| 247 |
+
if (domain === "web-game") {
|
| 248 |
+
return [
|
| 249 |
+
{ name: "Web Client (Phaser)", description: "Canvas game", dependencies: [] },
|
| 250 |
+
{ name: "Asset pipeline", description: "Original art", dependencies: ["Web Client (Phaser)"] },
|
| 251 |
+
{ name: "Level engine", description: "Data-driven levels", dependencies: ["Web Client (Phaser)"] },
|
| 252 |
+
{ name: "CI / Pages", description: "Build + deploy", dependencies: [] },
|
| 253 |
+
];
|
| 254 |
+
}
|
| 255 |
+
return [
|
| 256 |
+
{ name: "Web App", description: "Frontend", dependencies: [] },
|
| 257 |
+
{ name: "API", description: "Business logic", dependencies: ["Web App"] },
|
| 258 |
+
{ name: "Database", description: "Storage", dependencies: ["API"] },
|
| 259 |
+
{ name: "Worker / Queue", description: "Background jobs", dependencies: ["API"] },
|
| 260 |
+
{ name: "Admin Dashboard", description: "Ops & analytics", dependencies: ["Web App", "API"] },
|
| 261 |
+
];
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
function filePlan(domain: Domain): FilePlanItem[] {
|
| 265 |
+
if (domain === "web-game") {
|
| 266 |
+
return [
|
| 267 |
+
{ path: "src/scenes", description: "Boot/Preload/Game/…" },
|
| 268 |
+
{ path: "src/levels", description: "level data + builder" },
|
| 269 |
+
{ path: "src/entities", description: "Player, enemies, Coin" },
|
| 270 |
+
{ path: "public/assets", description: "original art" },
|
| 271 |
+
{ path: "README.md", description: "overview" },
|
| 272 |
+
];
|
| 273 |
+
}
|
| 274 |
+
return [
|
| 275 |
+
{ path: "apps/web", description: "Web app and dashboard" },
|
| 276 |
+
{ path: "services/api", description: "API and business logic" },
|
| 277 |
+
{ path: "services/worker", description: "Background jobs" },
|
| 278 |
+
{ path: "packages/shared", description: "Shared types" },
|
| 279 |
+
{ path: "db", description: "Migrations" },
|
| 280 |
+
{ path: "README.md", description: "overview" },
|
| 281 |
+
];
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function roadmap(domain: Domain): DesignerBatch[] {
|
| 285 |
+
const spec: [string, string[]][] =
|
| 286 |
+
domain === "web-game"
|
| 287 |
+
? [
|
| 288 |
+
["Game foundation", ["vite.config.ts", "src/main.ts"]],
|
| 289 |
+
["Asset pipeline", ["scripts/gen_assets.py", "public/assets/**"]],
|
| 290 |
+
["Tilemap world", ["src/levels/**"]],
|
| 291 |
+
["Player & controls", ["src/entities/Player.ts"]],
|
| 292 |
+
["Enemies & power-ups", ["src/entities/**"]],
|
| 293 |
+
["HUD & gate", ["src/ui/HUD.ts"]],
|
| 294 |
+
["Campaign & boss", ["src/levels/episodes.ts"]],
|
| 295 |
+
["Polish & release", [".github/workflows/deploy.yml"]],
|
| 296 |
+
]
|
| 297 |
+
: [
|
| 298 |
+
["Foundation", ["apps/web/**", "services/api/**"]],
|
| 299 |
+
["Integrations", ["services/api/**"]],
|
| 300 |
+
["Workflow & logic", ["services/api/**"]],
|
| 301 |
+
["Admin & analytics", ["apps/web/**"]],
|
| 302 |
+
["Hardening & deploy", ["docker-compose.yml", ".github/**"]],
|
| 303 |
+
["Validation & release", ["tests/**"]],
|
| 304 |
+
];
|
| 305 |
+
return spec.map(([name, allowed], i): DesignerBatch => ({
|
| 306 |
+
id: `batch-${String(i + 1).padStart(2, "0")}`,
|
| 307 |
+
name,
|
| 308 |
+
purpose: `${name}.`,
|
| 309 |
+
tasks: [name],
|
| 310 |
+
allowed_files: allowed,
|
| 311 |
+
depends_on: i > 0 ? [`batch-${String(i).padStart(2, "0")}`] : [],
|
| 312 |
+
acceptance_criteria: ["builds", "feature works", "no runtime errors"],
|
| 313 |
+
validation_checks: ["lint", "typecheck", "tests"],
|
| 314 |
+
must_not_change: [],
|
| 315 |
+
}));
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
function extra(start: number, count: number): DesignerBatch[] {
|
| 319 |
+
const names = ["Observability", "Performance pass", "Security review", "Docs & onboarding", "Accessibility"];
|
| 320 |
+
const out: DesignerBatch[] = [];
|
| 321 |
+
for (let k = 0; k < count; k++) {
|
| 322 |
+
const i = start + k + 1;
|
| 323 |
+
out.push({
|
| 324 |
+
id: `batch-${String(i).padStart(2, "0")}`,
|
| 325 |
+
name: names[k % names.length],
|
| 326 |
+
purpose: "Production hardening.",
|
| 327 |
+
tasks: ["hardening"],
|
| 328 |
+
allowed_files: ["**"],
|
| 329 |
+
depends_on: [`batch-${String(i - 1).padStart(2, "0")}`],
|
| 330 |
+
acceptance_criteria: ["meets the production bar"],
|
| 331 |
+
validation_checks: ["tests"],
|
| 332 |
+
must_not_change: [],
|
| 333 |
+
});
|
| 334 |
+
}
|
| 335 |
+
return out;
|
| 336 |
+
}
|
web/src/lib/blueprint-persistence.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// C4 — localStorage-first persistence for the Blueprint workspace.
|
| 2 |
+
//
|
| 3 |
+
// The workspace autosaves its single state object (which already embeds the chat history) to
|
| 4 |
+
// localStorage on every edit, keyed per idea + candidate. On mount the store rehydrates from it,
|
| 5 |
+
// so a reload restores the workspace with no server. All access is guarded + best-effort: a
|
| 6 |
+
// quota error or private-mode block degrades silently to in-memory only.
|
| 7 |
+
|
| 8 |
+
import type { BlueprintDetailsData } from "@/types/blueprint-state";
|
| 9 |
+
|
| 10 |
+
const PREFIX = "matrix-builder:blueprint:v1";
|
| 11 |
+
|
| 12 |
+
function hash(s: string): string {
|
| 13 |
+
let h = 5381;
|
| 14 |
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
| 15 |
+
return (h >>> 0).toString(36);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function workspaceKey(idea: string, candidateId: string): string {
|
| 19 |
+
return `${PREFIX}:${hash(idea.trim())}:${candidateId}`;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function saveWorkspace(key: string, data: BlueprintDetailsData): void {
|
| 23 |
+
if (typeof window === "undefined") return;
|
| 24 |
+
try {
|
| 25 |
+
window.localStorage.setItem(key, JSON.stringify(data));
|
| 26 |
+
} catch {
|
| 27 |
+
/* quota / private mode — keep working in memory */
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export function loadWorkspace(key: string): BlueprintDetailsData | null {
|
| 32 |
+
if (typeof window === "undefined") return null;
|
| 33 |
+
try {
|
| 34 |
+
const raw = window.localStorage.getItem(key);
|
| 35 |
+
if (!raw) return null;
|
| 36 |
+
const data = JSON.parse(raw) as BlueprintDetailsData;
|
| 37 |
+
return data && Array.isArray(data.batches) ? data : null;
|
| 38 |
+
} catch {
|
| 39 |
+
return null;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function clearWorkspace(key: string): void {
|
| 44 |
+
if (typeof window === "undefined") return;
|
| 45 |
+
try {
|
| 46 |
+
window.localStorage.removeItem(key);
|
| 47 |
+
} catch {
|
| 48 |
+
/* ignore */
|
| 49 |
+
}
|
| 50 |
+
}
|
web/src/lib/blueprint-store.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// C0 — Blueprint workspace store: one live state object, mutated in the browser.
|
| 2 |
+
//
|
| 3 |
+
// `useBlueprintWorkspace` is the single source of truth for the Details page. It generates the
|
| 4 |
+
// initial blueprint and applies chat instructions entirely via `blueprint-engine` (no network),
|
| 5 |
+
// so the workspace is instant and works fully offline. Two optional enhancements layer on top:
|
| 6 |
+
// • C4 persistence — the state autosaves to localStorage and rehydrates on reload (no server).
|
| 7 |
+
// • C3 AI refine — one bounded, fail-open call may improve the explanation prose afterward.
|
| 8 |
+
|
| 9 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 10 |
+
|
| 11 |
+
import * as engine from "@/lib/blueprint-engine";
|
| 12 |
+
import { refineWithAI } from "@/lib/ai-refine";
|
| 13 |
+
import { loadWorkspace, saveWorkspace, workspaceKey } from "@/lib/blueprint-persistence";
|
| 14 |
+
import type { BlueprintDetailsData, ChatMessage } from "@/types/blueprint-state";
|
| 15 |
+
|
| 16 |
+
const now = () => new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
| 17 |
+
|
| 18 |
+
function messagesFrom(data: BlueprintDetailsData): ChatMessage[] {
|
| 19 |
+
return (data.chat_history ?? []).map((m, i) => ({ id: m.id || `h-${i}`, role: m.role, content: m.content }));
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export interface BlueprintWorkspace {
|
| 23 |
+
data: BlueprintDetailsData;
|
| 24 |
+
messages: ChatMessage[];
|
| 25 |
+
dirty: boolean;
|
| 26 |
+
busy: boolean;
|
| 27 |
+
/** Apply a chat instruction locally (instant, no network) and patch the affected sections. */
|
| 28 |
+
applyInstruction: (text: string) => void;
|
| 29 |
+
/** Replace state with a refined version (optional AI/server enhancement). */
|
| 30 |
+
setData: (next: BlueprintDetailsData) => void;
|
| 31 |
+
markSaved: () => void;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function useBlueprintWorkspace(candidateId: string, idea: string): BlueprintWorkspace {
|
| 35 |
+
const [data, setDataState] = useState<BlueprintDetailsData>(() => engine.generate(candidateId, idea));
|
| 36 |
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
| 37 |
+
const [dirty, setDirty] = useState(false);
|
| 38 |
+
const [busy, setBusy] = useState(false);
|
| 39 |
+
|
| 40 |
+
const dataRef = useRef(data); // latest state, so applyInstruction never reads a stale closure
|
| 41 |
+
dataRef.current = data;
|
| 42 |
+
const keyRef = useRef(workspaceKey(idea, candidateId));
|
| 43 |
+
|
| 44 |
+
// C4 — rehydrate from localStorage if present, else generate locally. No fetch on the render path.
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
const key = workspaceKey(idea, candidateId);
|
| 47 |
+
keyRef.current = key;
|
| 48 |
+
const saved = loadWorkspace(key);
|
| 49 |
+
const next = saved ?? engine.generate(candidateId, idea);
|
| 50 |
+
dataRef.current = next;
|
| 51 |
+
setDataState(next);
|
| 52 |
+
setMessages(saved ? messagesFrom(saved) : []);
|
| 53 |
+
setDirty(false);
|
| 54 |
+
setBusy(false);
|
| 55 |
+
}, [candidateId, idea]);
|
| 56 |
+
|
| 57 |
+
const setData = useCallback((next: BlueprintDetailsData) => {
|
| 58 |
+
dataRef.current = next;
|
| 59 |
+
setDataState(next);
|
| 60 |
+
setMessages(messagesFrom(next));
|
| 61 |
+
saveWorkspace(keyRef.current, next);
|
| 62 |
+
}, []);
|
| 63 |
+
|
| 64 |
+
const applyInstruction = useCallback((text: string) => {
|
| 65 |
+
const clean = text.trim();
|
| 66 |
+
if (!clean || busy) return;
|
| 67 |
+
setMessages((m) => [...m, { id: `u-${Date.now()}`, role: "user", content: clean, time: now() }]);
|
| 68 |
+
setBusy(true);
|
| 69 |
+
// Defer a tick so the "thinking" affordance can paint — still zero network for the result.
|
| 70 |
+
window.setTimeout(() => {
|
| 71 |
+
const res = engine.apply(dataRef.current, clean);
|
| 72 |
+
const stamp = new Date().toISOString();
|
| 73 |
+
res.data.chat_history = [
|
| 74 |
+
...(dataRef.current.chat_history ?? []),
|
| 75 |
+
{ id: `uh-${Date.now()}`, role: "user", content: clean, timestamp: stamp },
|
| 76 |
+
{ id: `bh-${Date.now()}`, role: "blueprint", content: res.reply, timestamp: stamp },
|
| 77 |
+
];
|
| 78 |
+
dataRef.current = res.data;
|
| 79 |
+
setDataState(res.data);
|
| 80 |
+
setMessages((m) => [...m, { id: `b-${Date.now()}`, role: "blueprint", content: res.reply, time: now() }]);
|
| 81 |
+
setDirty(true);
|
| 82 |
+
setBusy(false);
|
| 83 |
+
saveWorkspace(keyRef.current, res.data); // C4 autosave
|
| 84 |
+
|
| 85 |
+
// C3 — optional, bounded, fail-open AI refinement of the explanation only.
|
| 86 |
+
void refineWithAI(res.data, clean)
|
| 87 |
+
.then((ref) => {
|
| 88 |
+
if (!ref) return;
|
| 89 |
+
const patched: BlueprintDetailsData = {
|
| 90 |
+
...dataRef.current,
|
| 91 |
+
overview: ref.overview ?? dataRef.current.overview,
|
| 92 |
+
design_brain: ref.designBrain ?? dataRef.current.design_brain,
|
| 93 |
+
};
|
| 94 |
+
dataRef.current = patched;
|
| 95 |
+
setDataState(patched);
|
| 96 |
+
saveWorkspace(keyRef.current, patched);
|
| 97 |
+
if (ref.reply) setMessages((m) => [...m, { id: `r-${Date.now()}`, role: "blueprint", content: `✦ ${ref.reply}`, time: now() }]);
|
| 98 |
+
})
|
| 99 |
+
.catch(() => undefined);
|
| 100 |
+
}, 140);
|
| 101 |
+
}, [busy]);
|
| 102 |
+
|
| 103 |
+
const markSaved = useCallback(() => setDirty(false), []);
|
| 104 |
+
|
| 105 |
+
return { data, messages, dirty, busy, applyInstruction, setData, markSaved };
|
| 106 |
+
}
|
web/src/lib/capabilities.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// C5 — Capability probe: is a control plane actually reachable?
|
| 2 |
+
//
|
| 3 |
+
// The Blueprint workspace is fully client-side (C0–C4). The server is an OPTIONAL sync target:
|
| 4 |
+
// callers gate every `/api/v1/blueprints/*` request behind `getCapabilities()`, so when no backend
|
| 5 |
+
// is present (static hosting, offline) the app makes zero per-interaction requests and the UX is
|
| 6 |
+
// identical. The probe runs at most once (cached), is timeout-bounded, and never throws.
|
| 7 |
+
|
| 8 |
+
import { apiBaseUrl } from "@/lib/api-client";
|
| 9 |
+
|
| 10 |
+
export interface Capabilities {
|
| 11 |
+
/** A control plane responded to /health — server sync/enhancement is available. */
|
| 12 |
+
server: boolean;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
let cache: Capabilities | null = null;
|
| 16 |
+
let inflight: Promise<Capabilities> | null = null;
|
| 17 |
+
|
| 18 |
+
export async function getCapabilities(opts: { timeoutMs?: number } = {}): Promise<Capabilities> {
|
| 19 |
+
if (cache) return cache;
|
| 20 |
+
if (inflight) return inflight;
|
| 21 |
+
if (typeof window === "undefined" || typeof fetch === "undefined") return { server: false };
|
| 22 |
+
|
| 23 |
+
inflight = (async () => {
|
| 24 |
+
let server = false;
|
| 25 |
+
try {
|
| 26 |
+
const ctrl = new AbortController();
|
| 27 |
+
const t = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 1500);
|
| 28 |
+
const res = await fetch(`${apiBaseUrl}/health`, { signal: ctrl.signal });
|
| 29 |
+
clearTimeout(t);
|
| 30 |
+
server = res.ok;
|
| 31 |
+
} catch {
|
| 32 |
+
server = false; // unreachable / blocked / refused — stay fully local
|
| 33 |
+
}
|
| 34 |
+
cache = { server };
|
| 35 |
+
inflight = null;
|
| 36 |
+
return cache;
|
| 37 |
+
})();
|
| 38 |
+
return inflight;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/** Test/runtime hook to forget the cached probe. */
|
| 42 |
+
export function resetCapabilities(): void {
|
| 43 |
+
cache = null;
|
| 44 |
+
inflight = null;
|
| 45 |
+
}
|
web/src/styles/matrix-builder.css
CHANGED
|
@@ -1492,3 +1492,190 @@ button,select,a{-webkit-tap-highlight-color:transparent;touch-action:manipulatio
|
|
| 1492 |
.mb-src-regen:hover{border-color:rgba(83,243,157,.45);color:#eafff4}
|
| 1493 |
.mb-src-regen svg{color:var(--grn-bright)}
|
| 1494 |
@media(max-width:640px){.mb-src-idea,.mb-src-edit{max-width:none;flex:1 1 100%;min-width:0}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1492 |
.mb-src-regen:hover{border-color:rgba(83,243,157,.45);color:#eafff4}
|
| 1493 |
.mb-src-regen svg{color:var(--grn-bright)}
|
| 1494 |
@media(max-width:640px){.mb-src-idea,.mb-src-edit{max-width:none;flex:1 1 100%;min-width:0}}
|
| 1495 |
+
|
| 1496 |
+
/* ============================================================================
|
| 1497 |
+
Blueprint Details — a calm, premium dark planning room (see BlueprintDetails)
|
| 1498 |
+
============================================================================ */
|
| 1499 |
+
/* Card secondary action: keep cards clean — Details is a quiet link, Choose stays primary. */
|
| 1500 |
+
.cand-actions{margin-top:6px;display:flex;align-items:center;gap:10px}
|
| 1501 |
+
.cand-actions .cand-choose{margin-top:0;flex:1}
|
| 1502 |
+
.cand-details{flex:none;background:transparent;border:1px solid var(--bd-light);color:var(--tx-muted);border-radius:11px;min-height:44px;padding:0 14px;font-weight:600;cursor:pointer;transition:.15s}
|
| 1503 |
+
.cand-details:hover{border-color:var(--grn);color:var(--grn-deep)}
|
| 1504 |
+
|
| 1505 |
+
/* Full-bleed dark surface */
|
| 1506 |
+
.bd-surface{background:linear-gradient(165deg,var(--bg-dark-2),var(--bg-dark) 60%);min-height:calc(100vh - 60px);color:#dff1e6}
|
| 1507 |
+
.bd-page{max-width:1180px}
|
| 1508 |
+
.bd-crumb{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px;color:#7fb8a0;margin-bottom:14px}
|
| 1509 |
+
.bd-crumb .bd-step{color:var(--grn-bright)}
|
| 1510 |
+
.bd-crumb .bd-sep{opacity:.4}
|
| 1511 |
+
.bd-crumb .bd-cur{color:#eafff4}
|
| 1512 |
+
.bd-head{margin-bottom:22px}
|
| 1513 |
+
.bd-h1{font-family:var(--serif);font-size:clamp(28px,4vw,40px);line-height:1.08;color:#fff;margin:0 0 10px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
| 1514 |
+
.bd-rec{font-family:var(--sans);font-size:12px;font-weight:700;color:var(--bg-dark);background:var(--grn-bright);border-radius:999px;padding:5px 11px}
|
| 1515 |
+
.bd-lede{color:#a9cbbb;font-size:16px;max-width:70ch;margin:0 0 14px}
|
| 1516 |
+
.bd-pills{display:flex;flex-wrap:wrap;gap:8px}
|
| 1517 |
+
.bd-page .mb-tag{background:rgba(255,255,255,.05);color:#cfe8db;border:1px solid rgba(120,200,160,.18)}
|
| 1518 |
+
.bd-page .mb-tag.n{background:rgba(255,255,255,.04);color:#9bb8aa}
|
| 1519 |
+
.bd-page .mb-tag.bd-ai{background:rgba(34,200,120,.16);color:var(--grn-bright);border-color:rgba(34,200,120,.35)}
|
| 1520 |
+
.bd-page .mb-tag.bd-lock{display:inline-flex;align-items:center;gap:5px}
|
| 1521 |
+
|
| 1522 |
+
.bd-grid{display:grid;grid-template-columns:1.55fr 1fr;gap:20px;align-items:start}
|
| 1523 |
+
.bd-col{display:flex;flex-direction:column;gap:18px}
|
| 1524 |
+
.bd-card{background:rgba(255,255,255,.035);border:1px solid rgba(120,200,160,.16);border-radius:18px;padding:20px 22px;backdrop-filter:blur(6px)}
|
| 1525 |
+
.bd-card-h{display:flex;align-items:center;gap:9px;margin-bottom:14px}
|
| 1526 |
+
.bd-card-h svg{color:var(--grn-bright)}
|
| 1527 |
+
.bd-card-h h3{font-size:16px;font-weight:700;color:#fff;margin:0}
|
| 1528 |
+
.bd-card-sub{margin-left:auto;font-family:var(--mono);font-size:11px;color:#8aae9d}
|
| 1529 |
+
.bd-p{color:#bcd8c9;font-size:14.5px;margin:0 0 14px}
|
| 1530 |
+
.bd-ov,.bd-brain-grid{display:flex;flex-direction:column;gap:12px}
|
| 1531 |
+
.bd-ov>div,.bd-brain-grid>div{display:flex;flex-direction:column;gap:3px}
|
| 1532 |
+
.bd-ov-k{font-family:var(--mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:#7fb8a0}
|
| 1533 |
+
.bd-ov-v{color:#dbeee3;font-size:14px}
|
| 1534 |
+
|
| 1535 |
+
/* Batches — thin, elegant ordered rows */
|
| 1536 |
+
.bd-batches{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
|
| 1537 |
+
.bd-batch{display:flex;gap:14px;padding:14px;border-radius:13px;background:rgba(0,0,0,.18);border:1px solid rgba(120,200,160,.10);border-left:2px solid var(--grn)}
|
| 1538 |
+
.bd-batch-n{font-family:var(--mono);font-size:13px;font-weight:700;color:var(--grn-bright);flex:none;width:26px;text-align:center;padding-top:1px}
|
| 1539 |
+
.bd-batch-body{flex:1;min-width:0}
|
| 1540 |
+
.bd-batch-top{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
| 1541 |
+
.bd-batch-name{font-weight:700;color:#eafff4;font-size:14.5px}
|
| 1542 |
+
.bd-batch-status{font-family:var(--mono);font-size:10.5px;color:#86a99a;border:1px solid rgba(120,200,160,.18);border-radius:999px;padding:2px 8px;flex:none}
|
| 1543 |
+
.bd-batch-purpose{color:#a9cbbb;font-size:13.5px;margin:5px 0 8px}
|
| 1544 |
+
.bd-batch-meta{display:flex;flex-wrap:wrap;gap:6px}
|
| 1545 |
+
.bd-batch-tag{font-family:var(--mono);font-size:10.5px;color:#8fb3a4;background:rgba(255,255,255,.04);border:1px solid rgba(120,200,160,.12);border-radius:7px;padding:3px 7px}
|
| 1546 |
+
|
| 1547 |
+
/* Design Brain */
|
| 1548 |
+
.bd-brain{border-color:rgba(138,99,210,.35);background:linear-gradient(180deg,rgba(138,99,210,.10),rgba(255,255,255,.03))}
|
| 1549 |
+
.bd-brain-ic{font-size:16px}
|
| 1550 |
+
|
| 1551 |
+
/* Talk to blueprint */
|
| 1552 |
+
.bd-chat-sub{margin:-6px 0 12px}
|
| 1553 |
+
.bd-save{margin-left:auto;width:34px;height:34px;border-radius:9px;display:grid;place-items:center;background:rgba(34,200,120,.14);border:1px solid rgba(34,200,120,.3);color:var(--grn-bright);cursor:pointer;transition:.15s}
|
| 1554 |
+
.bd-save:hover{background:rgba(34,200,120,.24)}
|
| 1555 |
+
.bd-chat-log{display:flex;flex-direction:column;gap:10px;margin-bottom:12px;max-height:230px;overflow-y:auto}
|
| 1556 |
+
.bd-msg{display:flex;flex-direction:column;gap:3px;padding:10px 12px;border-radius:11px;background:rgba(0,0,0,.2)}
|
| 1557 |
+
.bd-msg.user{background:rgba(34,200,120,.10)}
|
| 1558 |
+
.bd-msg-role{font-family:var(--mono);font-size:10.5px;color:#8aae9d}
|
| 1559 |
+
.bd-msg-text{color:#dbeee3;font-size:14px}
|
| 1560 |
+
.bd-composer{display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.25);border:1px solid rgba(120,200,160,.18);border-radius:13px;padding:6px 6px 6px 14px}
|
| 1561 |
+
.bd-composer:focus-within{border-color:var(--grn)}
|
| 1562 |
+
.bd-composer-input{flex:1;background:transparent;border:0;outline:0;color:#eafff4;font-size:14.5px;min-height:36px}
|
| 1563 |
+
.bd-composer-input::placeholder{color:#7c9a8c}
|
| 1564 |
+
.bd-composer-send{flex:none;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;background:var(--grn);color:#fff;border:0;cursor:pointer;transition:.15s}
|
| 1565 |
+
.bd-composer-send:disabled{opacity:.4;cursor:default}
|
| 1566 |
+
|
| 1567 |
+
/* Architecture nodes */
|
| 1568 |
+
.bd-arch{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
| 1569 |
+
.bd-arch-node{display:flex;flex-direction:column;gap:3px;padding:12px;border-radius:12px;background:rgba(0,0,0,.18);border:1px solid rgba(120,200,160,.16)}
|
| 1570 |
+
.bd-arch-ic{color:var(--grn-bright)}
|
| 1571 |
+
.bd-arch-label{font-weight:700;color:#eafff4;font-size:13.5px}
|
| 1572 |
+
.bd-arch-sub{font-family:var(--mono);font-size:10.5px;color:#88ab9c}
|
| 1573 |
+
|
| 1574 |
+
/* File plan */
|
| 1575 |
+
.bd-files{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:9px}
|
| 1576 |
+
.bd-file{display:flex;flex-direction:column;gap:2px}
|
| 1577 |
+
.bd-file-path{font-family:var(--mono);font-size:12.5px;color:var(--grn-bright)}
|
| 1578 |
+
.bd-file-why{font-size:13px;color:#a9cbbb}
|
| 1579 |
+
|
| 1580 |
+
/* Matrix rules */
|
| 1581 |
+
.bd-rules{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:11px}
|
| 1582 |
+
.bd-rules li{display:flex;align-items:center;gap:9px;color:#cfe8db;font-size:13.5px}
|
| 1583 |
+
.bd-rules svg{color:var(--grn-bright);flex:none}
|
| 1584 |
+
|
| 1585 |
+
/* Actions */
|
| 1586 |
+
.bd-actions{display:flex;flex-direction:column;gap:10px;margin-top:4px}
|
| 1587 |
+
.bd-back{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:46px;border-radius:12px;background:transparent;border:1px solid rgba(120,200,160,.22);color:#cfe8db;font-weight:600;cursor:pointer;transition:.15s}
|
| 1588 |
+
.bd-back:hover{border-color:var(--grn);color:#fff}
|
| 1589 |
+
.bd-choose{display:inline-flex;align-items:center;justify-content:center;gap:9px;min-height:52px;border-radius:13px;background:var(--grn);color:#fff;border:0;font-weight:700;font-size:15.5px;cursor:pointer;box-shadow:0 18px 40px -18px rgba(34,200,120,.8);transition:transform .15s,background .15s}
|
| 1590 |
+
.bd-choose:hover{transform:translateY(-1px);background:var(--grn-bright)}
|
| 1591 |
+
|
| 1592 |
+
@media (max-width:880px){
|
| 1593 |
+
.bd-grid{grid-template-columns:1fr}
|
| 1594 |
+
.bd-arch{grid-template-columns:1fr 1fr}
|
| 1595 |
+
}
|
| 1596 |
+
|
| 1597 |
+
/* Settings — Matrix Designer toggle badge */
|
| 1598 |
+
.md-badge{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--grn-deep);background:var(--grn-tint);border:1px solid rgba(18,138,82,.2);border-radius:999px;padding:2px 7px;margin-left:6px;vertical-align:middle}
|
| 1599 |
+
|
| 1600 |
+
/* Blueprint Details — batch-06/07 polish (unsaved badge, dependency graph, busy) */
|
| 1601 |
+
.bd-unsaved{margin-left:auto;font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#ffd27f;background:rgba(255,176,0,.14);border:1px solid rgba(255,176,0,.35);border-radius:999px;padding:2px 8px}
|
| 1602 |
+
.bd-chat .bd-card-h .bd-unsaved + .bd-save{margin-left:8px}
|
| 1603 |
+
.bd-save.on{background:rgba(34,200,120,.26);border-color:var(--grn)}
|
| 1604 |
+
.bd-depgraph{display:flex;flex-wrap:wrap;align-items:center;gap:4px}
|
| 1605 |
+
.bd-dep{display:inline-flex;align-items:center;gap:4px}
|
| 1606 |
+
.bd-dep-arrow{color:#6f9a88}
|
| 1607 |
+
.bd-dep-node{font-family:var(--mono);font-size:10.5px;color:var(--grn-bright);background:rgba(34,200,120,.10);border:1px solid rgba(34,200,160,.22);border-radius:6px;padding:1px 6px}
|
| 1608 |
+
.bd-typing{color:#8aae9d;font-style:italic}
|
| 1609 |
+
|
| 1610 |
+
/* ============================================================================
|
| 1611 |
+
Blueprint Details — image-exact redesign (sticky sidebar, calm Apple/Linear feel)
|
| 1612 |
+
============================================================================ */
|
| 1613 |
+
.bd-grid{align-items:start}
|
| 1614 |
+
.bd-aside{position:sticky;top:76px;align-self:start;padding-right:2px}
|
| 1615 |
+
|
| 1616 |
+
/* Overview — narrative left, scope/quality/outcome right */
|
| 1617 |
+
.bd-ov-split{display:grid;grid-template-columns:1.5fr 1fr;gap:30px}
|
| 1618 |
+
.bd-ov-split .bd-p{margin:0}
|
| 1619 |
+
.bd-ov{display:flex;flex-direction:column;gap:16px}
|
| 1620 |
+
.bd-ov-scope{display:flex;flex-direction:column;gap:4px}
|
| 1621 |
+
.bd-ov-row{display:flex;gap:30px}
|
| 1622 |
+
.bd-ov-row>div{display:flex;flex-direction:column;gap:4px}
|
| 1623 |
+
@media (max-width:880px){ .bd-ov-split{grid-template-columns:1fr} }
|
| 1624 |
+
|
| 1625 |
+
/* Build batches — thin rows, description inline, status pinned right */
|
| 1626 |
+
.bd-batch{align-items:center}
|
| 1627 |
+
.bd-batch-body{display:flex;flex-direction:column;gap:8px}
|
| 1628 |
+
.bd-batch-top{display:flex;align-items:baseline;gap:12px}
|
| 1629 |
+
.bd-batch-desc{color:#9bbcad;font-size:13px;font-weight:400}
|
| 1630 |
+
.bd-batch-status{margin-left:auto;display:inline-flex;align-items:center;gap:7px;flex:none;font-family:var(--mono);font-size:11px;color:#8fb3a4}
|
| 1631 |
+
.bd-status-dot{width:13px;height:13px;border-radius:50%;border:1.5px solid rgba(143,179,164,.5);position:relative}
|
| 1632 |
+
.bd-status-dot::after{content:"";position:absolute;left:3px;right:3px;top:5.5px;height:1.5px;background:rgba(143,179,164,.7)}
|
| 1633 |
+
|
| 1634 |
+
/* Card "View details" link */
|
| 1635 |
+
.bd-card-link{margin-top:14px;display:inline-flex;align-items:center;gap:6px;background:none;border:0;color:var(--grn-bright);font-size:13px;font-weight:600;cursor:pointer;padding:0}
|
| 1636 |
+
.bd-card-link:hover{color:#fff}
|
| 1637 |
+
|
| 1638 |
+
/* Architecture nodes — stacked title row + subtitle */
|
| 1639 |
+
.bd-arch-node{gap:6px}
|
| 1640 |
+
.bd-arch-top{display:flex;align-items:center;gap:8px}
|
| 1641 |
+
.bd-arch-ic{color:var(--grn-bright);display:grid;place-items:center}
|
| 1642 |
+
|
| 1643 |
+
/* Compact sidebar Design Brain */
|
| 1644 |
+
.bd-brain-k{font-family:var(--mono);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:#7fb8a0}
|
| 1645 |
+
.bd-brain-intent{margin:6px 0 0;color:#dbeee3;font-size:13.5px;line-height:1.5}
|
| 1646 |
+
.bd-brain-note{margin:10px 0 0;color:#9bbcad;font-size:12.5px}
|
| 1647 |
+
|
| 1648 |
+
/* Chat — tabs header */
|
| 1649 |
+
.bd-chat-head{display:flex;align-items:center;gap:12px;margin-bottom:14px}
|
| 1650 |
+
.bd-chat-tabs{display:flex;gap:18px}
|
| 1651 |
+
.bd-tab{background:none;border:0;color:#7c9a8c;font-weight:600;font-size:14px;cursor:pointer;padding:0 0 8px;border-bottom:2px solid transparent;margin-bottom:-1px}
|
| 1652 |
+
.bd-tab.on{color:#fff;border-bottom-color:var(--grn)}
|
| 1653 |
+
.bd-chat-empty{color:#7c9a8c;font-size:13.5px;margin:4px 0 0}
|
| 1654 |
+
|
| 1655 |
+
/* Chat — messages with avatars (no bubbles, Claude/ChatGPT style) */
|
| 1656 |
+
.bd-chat-log{display:flex;flex-direction:column;gap:18px;margin-bottom:16px;max-height:340px;overflow-y:auto;padding-right:4px}
|
| 1657 |
+
.bd-msg{display:flex;gap:12px;align-items:flex-start;background:none;padding:0;border-radius:0}
|
| 1658 |
+
.bd-msg.user{background:none}
|
| 1659 |
+
.bd-msg-av{flex:none;width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font-family:var(--mono);font-size:11px;font-weight:700;color:#dbeee3;background:rgba(255,255,255,.08);border:1px solid rgba(120,200,160,.2)}
|
| 1660 |
+
.bd-msg.blueprint .bd-msg-av{color:var(--grn-bright)}
|
| 1661 |
+
.bd-msg-body{display:flex;flex-direction:column;gap:4px;min-width:0}
|
| 1662 |
+
.bd-msg-head{display:flex;align-items:baseline;gap:9px}
|
| 1663 |
+
.bd-msg-role{font-weight:700;color:#eafff4;font-size:13.5px}
|
| 1664 |
+
.bd-msg-time{font-family:var(--mono);font-size:10.5px;color:#7c9a8c}
|
| 1665 |
+
.bd-msg-text{color:#c8e2d6;font-size:14px;line-height:1.5}
|
| 1666 |
+
|
| 1667 |
+
/* Activity feed */
|
| 1668 |
+
.bd-activity{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:12px}
|
| 1669 |
+
.bd-activity-row{display:flex;align-items:center;gap:10px;color:#c8e2d6;font-size:13.5px}
|
| 1670 |
+
.bd-activity-dot{width:6px;height:6px;border-radius:50%;background:var(--grn-bright);flex:none}
|
| 1671 |
+
|
| 1672 |
+
/* Composer — + button, ⌘↵ hint, send */
|
| 1673 |
+
.bd-composer{padding:8px 8px 8px 8px;gap:6px}
|
| 1674 |
+
.bd-composer-add{flex:none;width:38px;height:38px;border-radius:10px;display:grid;place-items:center;background:rgba(255,255,255,.05);border:1px solid rgba(120,200,160,.18);color:#cfe8db;cursor:pointer;transition:.15s}
|
| 1675 |
+
.bd-composer-add:hover{border-color:var(--grn);color:#fff}
|
| 1676 |
+
.bd-composer-input{padding-left:6px}
|
| 1677 |
+
.bd-kbd{flex:none;font-family:var(--mono);font-size:11px;color:#7c9a8c;background:rgba(255,255,255,.05);border:1px solid rgba(120,200,160,.16);border-radius:7px;padding:3px 8px}
|
| 1678 |
+
|
| 1679 |
+
/* cascade fixes for the redesign */
|
| 1680 |
+
.bd-batch-status{border:0;padding:0;background:none;border-radius:0}
|
| 1681 |
+
.bd-msg-role{font-family:var(--sans)}
|
web/src/types/ai-settings.ts
CHANGED
|
@@ -23,10 +23,18 @@ export type OllaBridgeSettings = {
|
|
| 23 |
deviceId: string;
|
| 24 |
};
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
export type MatrixAISettings = {
|
| 27 |
provider: AIProvider;
|
| 28 |
mode: AIMode;
|
| 29 |
ollabridge: OllaBridgeSettings;
|
|
|
|
| 30 |
};
|
| 31 |
|
| 32 |
export const AI_SETTINGS_STORAGE_KEY = "matrix-builder:ai-settings:v1";
|
|
@@ -45,4 +53,7 @@ export const DEFAULT_AI_SETTINGS: MatrixAISettings = {
|
|
| 45 |
pairToken: "",
|
| 46 |
deviceId: "",
|
| 47 |
},
|
|
|
|
|
|
|
|
|
|
| 48 |
};
|
|
|
|
| 23 |
deviceId: string;
|
| 24 |
};
|
| 25 |
|
| 26 |
+
// Optional enhancement: the "design brain". When enabled AND Internal AI (OllaBridge) assist is on,
|
| 27 |
+
// Matrix Builder uses the AI to design the full batch plan + the coder prompts for the next stages.
|
| 28 |
+
// Default OFF — it never changes the deterministic Matrix contract.
|
| 29 |
+
export type MatrixDesignerSettings = {
|
| 30 |
+
enabled: boolean;
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
export type MatrixAISettings = {
|
| 34 |
provider: AIProvider;
|
| 35 |
mode: AIMode;
|
| 36 |
ollabridge: OllaBridgeSettings;
|
| 37 |
+
matrixDesigner: MatrixDesignerSettings;
|
| 38 |
};
|
| 39 |
|
| 40 |
export const AI_SETTINGS_STORAGE_KEY = "matrix-builder:ai-settings:v1";
|
|
|
|
| 53 |
pairToken: "",
|
| 54 |
deviceId: "",
|
| 55 |
},
|
| 56 |
+
matrixDesigner: {
|
| 57 |
+
enabled: false,
|
| 58 |
+
},
|
| 59 |
};
|
web/src/types/blueprint-state.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// C0 — Client state model for the Blueprint Details workspace.
|
| 2 |
+
//
|
| 3 |
+
// One state object is the single source of truth for the live workspace. It is produced and
|
| 4 |
+
// mutated entirely in the browser by `blueprint-engine.ts` (no network on the render path);
|
| 5 |
+
// the server and the LLM are optional progressive enhancements layered on top.
|
| 6 |
+
|
| 7 |
+
export interface ArchitectureNode {
|
| 8 |
+
name: string;
|
| 9 |
+
description: string;
|
| 10 |
+
dependencies: string[];
|
| 11 |
+
}
|
| 12 |
+
export interface FilePlanItem {
|
| 13 |
+
path: string;
|
| 14 |
+
description: string;
|
| 15 |
+
}
|
| 16 |
+
export interface DesignerBatch {
|
| 17 |
+
id: string;
|
| 18 |
+
name: string;
|
| 19 |
+
purpose: string;
|
| 20 |
+
tasks: string[];
|
| 21 |
+
allowed_files: string[];
|
| 22 |
+
depends_on: string[];
|
| 23 |
+
acceptance_criteria: string[];
|
| 24 |
+
validation_checks: string[];
|
| 25 |
+
must_not_change: string[];
|
| 26 |
+
}
|
| 27 |
+
export interface DesignerChatMessage {
|
| 28 |
+
id: string;
|
| 29 |
+
role: "user" | "blueprint";
|
| 30 |
+
content: string;
|
| 31 |
+
timestamp: string;
|
| 32 |
+
}
|
| 33 |
+
export interface BlueprintDetailsData {
|
| 34 |
+
candidate_id: string;
|
| 35 |
+
overview: string;
|
| 36 |
+
architecture: ArchitectureNode[];
|
| 37 |
+
batches: DesignerBatch[];
|
| 38 |
+
file_plan: FilePlanItem[];
|
| 39 |
+
matrix_rules: string[];
|
| 40 |
+
acceptance_criteria: string[];
|
| 41 |
+
validation_plan: string[];
|
| 42 |
+
risks: string[];
|
| 43 |
+
assumptions: string[];
|
| 44 |
+
design_brain?: string | null;
|
| 45 |
+
chat_history: DesignerChatMessage[];
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
export interface DesignerCandidate {
|
| 49 |
+
id: string;
|
| 50 |
+
tier: string;
|
| 51 |
+
title: string;
|
| 52 |
+
summary: string;
|
| 53 |
+
file_count: number;
|
| 54 |
+
difficulty: string;
|
| 55 |
+
estimate: string;
|
| 56 |
+
stack: string[];
|
| 57 |
+
recommended: boolean;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export interface ChatMessage {
|
| 61 |
+
id: string;
|
| 62 |
+
role: "user" | "blueprint";
|
| 63 |
+
content: string;
|
| 64 |
+
time?: string;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/** Which sections an engine `apply()` touched — lets the UI know what changed. */
|
| 68 |
+
export type BlueprintSection = "overview" | "architecture" | "filePlan" | "batches" | "designBrain";
|
| 69 |
+
|
| 70 |
+
/** The single source of truth for the live workspace (held by `useBlueprintWorkspace`). */
|
| 71 |
+
export interface BlueprintWorkspaceState {
|
| 72 |
+
candidateId: string;
|
| 73 |
+
idea: string;
|
| 74 |
+
data: BlueprintDetailsData;
|
| 75 |
+
messages: ChatMessage[];
|
| 76 |
+
dirty: boolean;
|
| 77 |
+
busy: boolean;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
export const RMD_RULES = [
|
| 81 |
+
"RMD-101: AI coders are workers, not architects.",
|
| 82 |
+
"RMD-103: Control files are protected.",
|
| 83 |
+
"RMD-111: Acceptance criteria are law.",
|
| 84 |
+
];
|