diff --git a/pyproject.toml b/pyproject.toml index 53c7bda..b6b46ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ [project.scripts] didactopus-api = "didactopus.api:main" +didactopus-export-svg = "didactopus.export_svg:main" +didactopus-render-bundle = "didactopus.render_bundle:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index 38663d9..9ee62c4 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -1,12 +1,17 @@ from __future__ import annotations -from fastapi import FastAPI, HTTPException, Header, Depends +from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import uvicorn from .db import Base, engine -from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState -from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state +from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, MediaRenderRequest +from .repository import ( + authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, + list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state, + create_render_job, update_render_job, list_render_jobs, list_artifacts +) from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id -from .engine import build_graph_frames +from .engine import build_graph_frames, stable_layout +from .worker import process_render_job Base.metadata.create_all(bind=engine) @@ -87,6 +92,12 @@ def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(c raise HTTPException(status_code=400, detail="Learner ID mismatch") return save_learner_state(state).model_dump() +@app.get("/api/packs/{pack_id}/layout") +def api_pack_layout(pack_id: str, user = Depends(current_user)): + ensure_pack_access(user, pack_id) + pack = get_pack(pack_id) + return {"pack_id": pack_id, "layout": stable_layout(pack)} if pack else {"pack_id": pack_id, "layout": {}} + @app.get("/api/learners/{learner_id}/graph-animation/{pack_id}") def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)): ensure_learner_access(user, learner_id) @@ -99,8 +110,36 @@ def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_us "pack_id": pack_id, "pack_title": pack.title if pack else "", "frames": frames, - "concepts": [{"id": c.id, "title": c.title, "prerequisites": c.prerequisites} for c in pack.concepts] if pack else [], + "concepts": [{"id": c.id, "title": c.title, "prerequisites": c.prerequisites, "cross_pack_links": [l.model_dump() for l in c.cross_pack_links]} for c in pack.concepts] if pack else [], } +@app.post("/api/learners/{learner_id}/render-jobs/{pack_id}") +def api_render_job(learner_id: str, pack_id: str, payload: MediaRenderRequest, background_tasks: BackgroundTasks, user = Depends(current_user)): + ensure_learner_access(user, learner_id) + ensure_pack_access(user, pack_id) + pack = get_pack(pack_id) + state = load_learner_state(learner_id) + animation = { + "learner_id": learner_id, + "pack_id": pack_id, + "pack_title": pack.title if pack else "", + "frames": build_graph_frames(state, pack), + } + job_id = create_render_job(learner_id, pack_id, payload.format, payload.fps, payload.theme) + background_tasks.add_task(process_render_job, job_id, learner_id, pack_id, payload.format, payload.fps, payload.theme, animation) + return {"job_id": job_id, "status": "queued"} + +@app.get("/api/render-jobs") +def api_list_render_jobs(learner_id: str | None = None, user = Depends(current_user)): + if learner_id: + ensure_learner_access(user, learner_id) + return list_render_jobs(learner_id) + +@app.get("/api/artifacts") +def api_list_artifacts(learner_id: str | None = None, user = Depends(current_user)): + if learner_id: + ensure_learner_access(user, learner_id) + return list_artifacts(learner_id) + def main(): uvicorn.run(app, host="127.0.0.1", port=8011) diff --git a/src/didactopus/engine.py b/src/didactopus/engine.py index f9032e0..6b7c236 100644 --- a/src/didactopus/engine.py +++ b/src/didactopus/engine.py @@ -1,11 +1,41 @@ from __future__ import annotations -from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData +from collections import defaultdict +from .models import LearnerState, PackData -def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery") -> MasteryRecord | None: - for rec in state.records: - if rec.concept_id == concept_id and rec.dimension == dimension: - return rec - return None +def concept_depths(pack: PackData) -> dict[str, int]: + concept_map = {c.id: c for c in pack.concepts} + memo = {} + def depth(cid: str) -> int: + if cid in memo: + return memo[cid] + c = concept_map[cid] + if not c.prerequisites: + memo[cid] = 0 + else: + memo[cid] = 1 + max(depth(pid) for pid in c.prerequisites if pid in concept_map) + return memo[cid] + for cid in concept_map: + depth(cid) + return memo + +def stable_layout(pack: PackData, width: int = 900, height: int = 520): + depths = concept_depths(pack) + layers = defaultdict(list) + for c in pack.concepts: + layers[depths.get(c.id, 0)].append(c) + positions = {} + max_depth = max(layers.keys()) if layers else 0 + for d in sorted(layers): + nodes = sorted(layers[d], key=lambda c: c.id) + y = 90 + d * ((height - 160) / max(1, max_depth)) + for idx, node in enumerate(nodes): + if node.position is not None: + positions[node.id] = {"x": node.position.x, "y": node.position.y, "source": "pack_authored"} + else: + spacing = width / (len(nodes) + 1) + x = spacing * (idx + 1) + positions[node.id] = {"x": x, "y": y, "source": "auto_layered"} + return positions def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool: for pid in concept.prerequisites: @@ -23,9 +53,18 @@ def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) - def build_graph_frames(state: LearnerState, pack: PackData): concepts = {c.id: c for c in pack.concepts} + layout = stable_layout(pack) scores = {c.id: 0.0 for c in pack.concepts} frames = [] history = sorted(state.history, key=lambda x: x.timestamp) + static_edges = [{"source": pre, "target": c.id, "kind": "prerequisite"} for c in pack.concepts for pre in c.prerequisites] + static_cross = [{ + "source": c.id, + "target_pack_id": link.target_pack_id, + "target_concept_id": link.target_concept_id, + "relationship": link.relationship, + "kind": "cross_pack" + } for c in pack.concepts for link in c.cross_pack_links] for idx, ev in enumerate(history): if ev.concept_id in scores: scores[ev.concept_id] = ev.score @@ -33,27 +72,39 @@ def build_graph_frames(state: LearnerState, pack: PackData): for cid, concept in concepts.items(): score = scores.get(cid, 0.0) status = concept_status(scores, concept) + pos = layout[cid] nodes.append({ "id": cid, "title": concept.title, "score": score, "status": status, "size": 20 + int(score * 30), + "x": pos["x"], + "y": pos["y"], + "layout_source": pos["source"], }) - edges = [] - for cid, concept in concepts.items(): - for pre in concept.prerequisites: - edges.append({"source": pre, "target": cid}) frames.append({ "index": idx, "timestamp": ev.timestamp, "event_kind": ev.kind, "focus_concept_id": ev.concept_id, "nodes": nodes, - "edges": edges, + "edges": static_edges, + "cross_pack_links": static_cross, }) if not frames: - nodes = [{"id": c.id, "title": c.title, "score": 0.0, "status": "available" if not c.prerequisites else "locked", "size": 20} for c in pack.concepts] - edges = [{"source": pre, "target": c.id} for c in pack.concepts for pre in c.prerequisites] - frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": edges}) + nodes = [] + for c in pack.concepts: + pos = layout[c.id] + nodes.append({ + "id": c.id, + "title": c.title, + "score": 0.0, + "status": "available" if not c.prerequisites else "locked", + "size": 20, + "x": pos["x"], + "y": pos["y"], + "layout_source": pos["source"], + }) + frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": static_edges, "cross_pack_links": static_cross}) return frames diff --git a/src/didactopus/models.py b/src/didactopus/models.py index b1db4f5..fb9adf3 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -15,12 +15,24 @@ class LoginRequest(BaseModel): class RefreshRequest(BaseModel): refresh_token: str +class GraphPosition(BaseModel): + x: float + y: float + +class CrossPackLink(BaseModel): + source_concept_id: str + target_pack_id: str + target_concept_id: str + relationship: str = "related" + class PackConcept(BaseModel): id: str title: str prerequisites: list[str] = Field(default_factory=list) masteryDimension: str = "mastery" exerciseReward: str = "" + position: GraphPosition | None = None + cross_pack_links: list[CrossPackLink] = Field(default_factory=list) class PackData(BaseModel): id: str @@ -56,3 +68,10 @@ class LearnerState(BaseModel): learner_id: str records: list[MasteryRecord] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list) + +class MediaRenderRequest(BaseModel): + learner_id: str + pack_id: str + format: str = "gif" + fps: int = 2 + theme: str = "default" diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index 49c1d57..3a0d355 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -56,3 +56,30 @@ class EvidenceEventORM(Base): timestamp: Mapped[str] = mapped_column(String(100), default="") kind: Mapped[str] = mapped_column(String(50), default="exercise") source_id: Mapped[str] = mapped_column(String(255), default="") + +class RenderJobORM(Base): + __tablename__ = "render_jobs" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + learner_id: Mapped[str] = mapped_column(String(100), index=True) + pack_id: Mapped[str] = mapped_column(String(100), index=True) + requested_format: Mapped[str] = mapped_column(String(20), default="gif") + fps: Mapped[int] = mapped_column(Integer, default=2) + theme: Mapped[str] = mapped_column(String(100), default="default") + status: Mapped[str] = mapped_column(String(50), default="queued") + bundle_dir: Mapped[str] = mapped_column(Text, default="") + payload_json: Mapped[str] = mapped_column(Text, default="") + manifest_path: Mapped[str] = mapped_column(Text, default="") + script_path: Mapped[str] = mapped_column(Text, default="") + error_text: Mapped[str] = mapped_column(Text, default="") + +class ArtifactORM(Base): + __tablename__ = "artifacts" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + render_job_id: Mapped[int] = mapped_column(ForeignKey("render_jobs.id"), index=True) + learner_id: Mapped[str] = mapped_column(String(100), index=True) + pack_id: Mapped[str] = mapped_column(String(100), index=True) + artifact_type: Mapped[str] = mapped_column(String(50), default="render_bundle") + format: Mapped[str] = mapped_column(String(20), default="gif") + title: Mapped[str] = mapped_column(String(255), default="") + path: Mapped[str] = mapped_column(Text, default="") + metadata_json: Mapped[str] = mapped_column(Text, default="{}") diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index a3723f5..bdc7b86 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -4,7 +4,7 @@ from .db import Base, engine, SessionLocal from .orm import UserORM from .auth import hash_password from .repository import upsert_pack, create_learner -from .models import PackData, PackConcept +from .models import PackData, PackConcept, GraphPosition, CrossPackLink def main(): Base.metadata.create_all(bind=engine) @@ -20,9 +20,10 @@ def main(): subtitle="Personal pack example.", level="novice-friendly", concepts=[ - PackConcept(id="intro", title="Intro", prerequisites=[]), - PackConcept(id="second", title="Second concept", prerequisites=["intro"]), - PackConcept(id="third", title="Third concept", prerequisites=["second"]), + PackConcept(id="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)), + PackConcept(id="second", title="Second concept", prerequisites=["intro"], position=GraphPosition(x=420, y=120)), + PackConcept(id="third", title="Third concept", prerequisites=["second"], position=GraphPosition(x=700, y=120), cross_pack_links=[CrossPackLink(source_concept_id="third", target_pack_id="advanced-pack", target_concept_id="adv-1", relationship="next_pack")]), + PackConcept(id="branch", title="Branch concept", prerequisites=["intro"], position=GraphPosition(x=420, y=320)), ], onboarding={"headline":"Start privately"}, compliance={} diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py index ad99705..28b6809 100644 --- a/src/didactopus/worker.py +++ b/src/didactopus/worker.py @@ -1,21 +1,37 @@ from __future__ import annotations -from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state -from .engine import apply_evidence -from .models import EvidenceEvent -import time +import json, tempfile +from pathlib import Path +from .repository import update_render_job, register_artifact +from .render_bundle import make_render_bundle -def process_job(job_id: int): - job = get_evaluator_job(job_id) - if job is None: - return - score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62 - confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45 - notes = "Prototype evaluator: longer responses scored somewhat higher." - update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]}) - state = load_learner_state(job.learner_id) - state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}")) - save_learner_state(state) - -def main(): - while True: - time.sleep(60) +def process_render_job(job_id: int, learner_id: str, pack_id: str, fmt: str, fps: int, theme: str, animation_payload: dict): + update_render_job(job_id, status="running") + try: + base = Path(tempfile.mkdtemp(prefix=f"didactopus_job_{job_id}_")) + payload_json = base / "animation_payload.json" + payload_json.write_text(json.dumps(animation_payload, indent=2), encoding="utf-8") + out_dir = base / "bundle" + make_render_bundle(str(payload_json), str(out_dir), fps=fps, fmt=fmt) + manifest_path = out_dir / "render_manifest.json" + script_path = out_dir / "render.sh" + update_render_job( + job_id, + status="completed", + bundle_dir=str(out_dir), + payload_json=str(payload_json), + manifest_path=str(manifest_path), + script_path=str(script_path), + error_text="", + ) + register_artifact( + render_job_id=job_id, + learner_id=learner_id, + pack_id=pack_id, + artifact_type="render_bundle", + fmt=fmt, + title=f"{pack_id} animation bundle", + path=str(out_dir), + metadata={"fps": fps, "theme": theme, "manifest_path": str(manifest_path), "script_path": str(script_path)}, + ) + except Exception as e: + update_render_job(job_id, status="failed", error_text=str(e)) diff --git a/tests/test_scaffold_files.py b/tests/test_scaffold_files.py index 2e9b522..6b26544 100644 --- a/tests/test_scaffold_files.py +++ b/tests/test_scaffold_files.py @@ -3,5 +3,6 @@ from pathlib import Path def test_scaffold_files_exist(): assert Path("src/didactopus/api.py").exists() assert Path("src/didactopus/repository.py").exists() + assert Path("src/didactopus/worker.py").exists() + assert Path("src/didactopus/render_bundle.py").exists() assert Path("webui/src/App.jsx").exists() - assert Path("webui/src/api.js").exists() diff --git a/webui/index.html b/webui/index.html index 0214f55..fec3a06 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@ - Didactopus Animated Concept Graph + Didactopus Artifact Registry
diff --git a/webui/package.json b/webui/package.json index f9f85ae..e4e7051 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,5 +1,5 @@ { - "name": "didactopus-animated-concept-graph-ui", + "name": "didactopus-artifact-registry-ui", "private": true, "version": "0.1.0", "type": "module", diff --git a/webui/src/App.jsx b/webui/src/App.jsx index d16151d..d592555 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,5 +1,5 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation } from "./api"; +import React, { useEffect, useState } from "react"; +import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createRenderJob, listRenderJobs, listArtifacts } from "./api"; import { loadAuth, saveAuth, clearAuth } from "./authStore"; function LoginView({ onAuth }) { @@ -26,56 +26,15 @@ function LoginView({ onAuth }) { ); } -function nodeColor(status) { - if (status === "mastered") return "#1f7a1f"; - if (status === "active") return "#2d6cdf"; - if (status === "available") return "#c48a00"; - return "#9aa4b2"; -} - -function GraphView({ frame }) { - if (!frame) return null; - const width = 760; - const height = 420; - const positions = {}; - frame.nodes.forEach((node, idx) => { - positions[node.id] = { x: 120 + idx * 220, y: 120 + (idx % 2) * 150 }; - }); - return ( - - {frame.edges.map((edge, idx) => { - const s = positions[edge.source]; - const t = positions[edge.target]; - if (!s || !t) return null; - return ; - })} - - - - - - {frame.nodes.map((node) => { - const p = positions[node.id]; - return ( - - - {node.title} - {node.score.toFixed(2)} ยท {node.status} - - ); - })} - - ); -} - export default function App() { const [auth, setAuth] = useState(loadAuth()); const [packs, setPacks] = useState([]); const [learnerId] = useState("wesley-learner"); const [packId, setPackId] = useState(""); - const [graphData, setGraphData] = useState(null); - const [frameIndex, setFrameIndex] = useState(0); - const [playing, setPlaying] = useState(false); + const [jobs, setJobs] = useState([]); + const [artifacts, setArtifacts] = useState([]); + const [format, setFormat] = useState("gif"); + const [fps, setFps] = useState(2); const [message, setMessage] = useState(""); async function refreshAuthToken() { @@ -101,10 +60,9 @@ export default function App() { } } - async function reload(pid) { - const data = await guarded((token) => fetchGraphAnimation(token, learnerId, pid)); - setGraphData(data); - setFrameIndex(0); + async function reloadLists() { + setJobs(await guarded((token) => listRenderJobs(token, learnerId))); + setArtifacts(await guarded((token) => listArtifacts(token, learnerId))); } useEffect(() => { @@ -112,53 +70,38 @@ export default function App() { async function load() { const p = await guarded((token) => fetchPacks(token)); setPacks(p); - const pid = p[0]?.id || ""; - setPackId(pid); - if (pid) await reload(pid); + setPackId(p[0]?.id || ""); + await reloadLists(); } load(); }, [auth]); - useEffect(() => { - if (!playing || !graphData?.frames?.length) return; - const t = setInterval(() => { - setFrameIndex((idx) => idx >= graphData.frames.length - 1 ? 0 : idx + 1); - }, 900); - return () => clearInterval(t); - }, [playing, graphData]); - - const frame = graphData?.frames?.[frameIndex]; - async function generateDemo() { let state = await guarded((token) => fetchLearnerState(token, learnerId)); - const now1 = new Date().toISOString(); - state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.30, confidence_hint: 0.5, timestamp: now1, kind: "exercise", source_id: "demo-1" }); - state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.30, confidence: 0.30, evidence_count: 1, last_updated: now1 }]; - await guarded((token) => putLearnerState(token, learnerId, state)); - - const now2 = new Date(Date.now() + 1000).toISOString(); - state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.78, confidence_hint: 0.7, timestamp: now2, kind: "review", source_id: "demo-2" }); - state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 }]; - await guarded((token) => putLearnerState(token, learnerId, state)); - - const now3 = new Date(Date.now() + 2000).toISOString(); - state.history.push({ concept_id: "second", dimension: "mastery", score: 0.42, confidence_hint: 0.5, timestamp: now3, kind: "exercise", source_id: "demo-3" }); - state.records = [ - { concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 }, - { concept_id: "second", dimension: "mastery", score: 0.42, confidence: 0.40, evidence_count: 1, last_updated: now3 } + const base = Date.now() + const events = [ + ["intro", 0.30, "exercise", 0], + ["intro", 0.78, "review", 1000], + ["second", 0.42, "exercise", 2000], + ["second", 0.72, "review", 3000], + ["third", 0.25, "exercise", 4000], + ["branch", 0.60, "exercise", 5000], ]; + const latest = {} + for (const [cid, score, kind, offset] of events) { + const ts = new Date(base + offset).toISOString(); + state.history.push({ concept_id: cid, dimension: "mastery", score, confidence_hint: 0.6, timestamp: ts, kind, source_id: `demo-${cid}-${offset}` }); + latest[cid] = { concept_id: cid, dimension: "mastery", score, confidence: Math.min(0.9, score), evidence_count: (latest[cid]?.evidence_count || 0) + 1, last_updated: ts }; + } + state.records = Object.values(latest); await guarded((token) => putLearnerState(token, learnerId, state)); + setMessage("Demo state generated."); + } - const now4 = new Date(Date.now() + 3000).toISOString(); - state.history.push({ concept_id: "second", dimension: "mastery", score: 0.72, confidence_hint: 0.7, timestamp: now4, kind: "review", source_id: "demo-4" }); - state.records = [ - { concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 }, - { concept_id: "second", dimension: "mastery", score: 0.72, confidence: 0.65, evidence_count: 2, last_updated: now4 } - ]; - await guarded((token) => putLearnerState(token, learnerId, state)); - - await reload(packId); - setMessage("Demo graph animation frames generated."); + async function createJob() { + const result = await guarded((token) => createRenderJob(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default" })); + setMessage(`Render job ${result.job_id} queued.`); + setTimeout(() => reloadLists(), 500); } if (!auth) return ; @@ -167,36 +110,40 @@ export default function App() {
-

Didactopus animated concept graph

-

Replay node-level mastery, unlocks, and prerequisite structure over time.

+

Didactopus artifact registry

+

Track render jobs and produced media artifacts as first-class Didactopus objects.

{message}
- - + + + + +
-

Graph animation

-
-
Frame: {frameIndex + 1} / {graphData?.frames?.length || 0}
-
Event: {frame?.event_kind || "-"}
-
Focus: {frame?.focus_concept_id || "-"}
-
Timestamp: {frame?.timestamp || "-"}
-
- +

Render jobs

+
{JSON.stringify(jobs, null, 2)}
-

Graph payload

-
{JSON.stringify(graphData, null, 2)}
+

Artifacts

+
{JSON.stringify(artifacts, null, 2)}
diff --git a/webui/src/api.js b/webui/src/api.js index 975a975..aaefb3d 100644 --- a/webui/src/api.js +++ b/webui/src/api.js @@ -19,4 +19,6 @@ export async function refresh(refreshToken) { export async function fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); } export async function fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); } export async function putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); } -export async function fetchGraphAnimation(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/graph-animation/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchGraphAnimation failed"); return await res.json(); } +export async function createRenderJob(token, learnerId, packId, payload) { const res = await fetch(`${API}/learners/${learnerId}/render-jobs/${packId}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createRenderJob failed"); return await res.json(); } +export async function listRenderJobs(token, learnerId) { const res = await fetch(`${API}/render-jobs?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listRenderJobs failed"); return await res.json(); } +export async function listArtifacts(token, learnerId) { const res = await fetch(`${API}/artifacts?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listArtifacts failed"); return await res.json(); } diff --git a/webui/src/styles.css b/webui/src/styles.css index 516cd27..e000ac1 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -10,7 +10,6 @@ body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); label { display:block; font-weight:600; } input, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; } button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; } -.primary { background:var(--accent); color:white; border-color:var(--accent); } .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; } .narrow { margin-top:60px; } .layout { display:grid; gap:16px; } @@ -18,12 +17,7 @@ button { border:1px solid var(--border); background:white; border-radius:12px; p .prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; } .muted { color:var(--muted); } .error { color:#b42318; margin-top:10px; } -.frame-meta { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:16px; } -.graph { width:100%; height:auto; border:1px solid var(--border); border-radius:16px; background:#fbfcfe; } -.svg-label { font-size:12px; fill:#fff; font-weight:bold; } -.svg-small { font-size:10px; fill:#fff; } @media (max-width:1100px) { .hero { flex-direction:column; } .twocol { grid-template-columns:1fr; } - .frame-meta { grid-template-columns:1fr; } }