diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 011b71a..4b94aed 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -39,7 +39,8 @@ Optional environment variables: | `/projects` | Project registry list (#427) | | `/projects/{id}` | Project detail + onboarding checklist | | `/api/projects` | JSON registry export | -| `/prompts` | Stub — prompt library (#428) | +| `/prompts` | Prompt library with per-prompt copy buttons (#428) | +| `/api/prompts` | JSON prompt export with workflow hashes | | `/runtime` | Stub — MCP runtime health (#430) | | `/audit` | Stub — report audit paste (#431) | | `/worktrees` | Stub — hygiene dashboard (#432) | @@ -60,8 +61,16 @@ or credentials. Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`, `prgs-reviewer`, and `prgs-reconciler` profiles. +## Prompt library (#428) + +Prompts are generated at load time from canonical workflow files under +`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from +`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short +copy/paste starters; canonical workflow files remain the only full policy +source. + ## Tests ```bash -pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py -q ``` \ No newline at end of file diff --git a/tests/test_webui_prompt_library.py b/tests/test_webui_prompt_library.py new file mode 100644 index 0000000..442800d --- /dev/null +++ b/tests/test_webui_prompt_library.py @@ -0,0 +1,81 @@ +"""Tests for web UI prompt library (#428).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.prompt_library import load_prompt_library, prompt_to_dict + + +class TestWebuiPromptLibrary(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_library_has_required_prompts(self): + entries = load_prompt_library() + slugs = {entry.slug for entry in entries} + self.assertEqual( + slugs, + { + "review-pr", + "work-issue", + "create-issue", + "comment-issue", + "cleanup", + "audit", + "onboarding", + }, + ) + + def test_workflow_entries_include_hash_and_task_mode(self): + review = next(e for e in load_prompt_library() if e.slug == "review-pr") + self.assertEqual( + review.workflow_path, + "skills/llm-project-workflow/workflows/review-merge-pr.md", + ) + self.assertEqual(review.task_mode, "review-merge-pr") + self.assertIsNotNone(review.workflow_hash) + self.assertGreater(len(review.workflow_hash), 12) + self.assertIn("eligible open PR", review.prompt_text) + + def test_comment_issue_defers_to_workflow(self): + comment = next(e for e in load_prompt_library() if e.slug == "comment-issue") + self.assertIn("comment_issue", comment.prompt_text) + self.assertIn("create-issue", comment.workflow_path) + self.assertIn("§16", comment.source_note) + + def test_prompts_page_renders_cards_and_copy_buttons(self): + response = self.client.get("/prompts") + self.assertEqual(response.status_code, 200) + text = response.text + self.assertIn("Prompt library", text) + self.assertIn("review-merge-pr.md", text) + self.assertIn("Copy prompt", text) + self.assertIn('data-copy-target="prompt-review-pr"', text) + self.assertNotIn("child issue", text.lower()) + + def test_api_prompts_json(self): + response = self.client.get("/api/prompts") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["count"], 7) + self.assertEqual(len(data["prompts"]), 7) + first = data["prompts"][0] + self.assertIn("slug", first) + self.assertIn("prompt_text", first) + self.assertIn("workflow_path", first) + self.assertIn("workflow_hash", first) + + def test_prompt_to_dict_round_trip(self): + entry = load_prompt_library()[0] + payload = prompt_to_dict(entry) + self.assertEqual(payload["slug"], entry.slug) + self.assertEqual(payload["prompt_text"], entry.prompt_text) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 7e139c6..937b095 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -30,12 +30,17 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Read-only MVP", response.text) def test_route_stubs_render(self): - for path in ("/prompts", "/runtime", "/audit"): + for path in ("/runtime", "/audit"): with self.subTest(path=path): response = self.client.get(path) self.assertEqual(response.status_code, 200) self.assertIn("child issue", response.text.lower()) + def test_prompts_is_implemented(self): + response = self.client.get("/prompts") + self.assertEqual(response.status_code, 200) + self.assertIn("Prompt library", response.text) + def test_projects_is_implemented(self): response = self.client.get("/projects") self.assertEqual(response.status_code, 200) diff --git a/webui/app.py b/webui/app.py index 4f1cba9..dc99cdb 100644 --- a/webui/app.py +++ b/webui/app.py @@ -12,6 +12,8 @@ from starlette.routing import Route from webui.layout import render_page from webui.project_registry import find_project, load_registry, registry_to_dict from webui.project_views import render_project_detail, render_projects_list +from webui.prompt_library import library_to_dict +from webui.prompt_views import render_prompts_page _READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) @@ -80,10 +82,11 @@ async def api_projects(_request: Request) -> JSONResponse: async def prompts(_request: Request) -> HTMLResponse: - return _stub_page( - "Prompts", - "Prompt library will surface canonical workflows from skills/llm-project-workflow/.", - ) + return HTMLResponse(render_prompts_page()) + + +async def api_prompts(_request: Request) -> JSONResponse: + return JSONResponse(library_to_dict()) async def runtime(_request: Request) -> HTMLResponse: @@ -134,6 +137,7 @@ def create_app() -> Starlette: Route("/projects/{project_id}", project_detail, methods=["GET"]), Route("/api/projects", api_projects, methods=["GET"]), Route("/prompts", prompts, methods=["GET"]), + Route("/api/prompts", api_prompts, methods=["GET"]), Route("/runtime", runtime, methods=["GET"]), Route("/audit", audit, methods=["GET"]), Route("/worktrees", worktrees, methods=["GET"]), diff --git a/webui/layout.py b/webui/layout.py index fc75efb..d948204 100644 --- a/webui/layout.py +++ b/webui/layout.py @@ -18,7 +18,7 @@ MVP_NOTICE = ( ) -def render_page(*, title: str, body_html: str) -> str: +def render_page(*, title: str, body_html: str, extra_head: str = "") -> str: nav_links = "".join( f'{label}' for href, label in NAV_ITEMS ) @@ -115,7 +115,38 @@ def render_page(*, title: str, body_html: str) -> str: }} ol.checklist li {{ margin-bottom: 0.85rem; }} ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }} + .prompt-card {{ + margin: 1.25rem 0 1.75rem; + padding: 1rem 1.1rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + }} + .prompt-card h3 {{ margin: 0 0 0.5rem; font-size: 1.05rem; }} + pre.prompt-text {{ + white-space: pre-wrap; + word-break: break-word; + margin: 0.75rem 0; + padding: 0.75rem 0.85rem; + border-radius: 6px; + background: var(--bg); + border: 1px solid var(--border); + font-size: 0.9rem; + color: var(--text); + }} + .copy-btn {{ + background: var(--accent); + color: #0b1219; + border: none; + border-radius: 6px; + padding: 0.4rem 0.85rem; + font-size: 0.85rem; + cursor: pointer; + }} + .copy-btn:hover {{ filter: brightness(1.08); }} + .muted {{ color: var(--muted); }} + {extra_head}
Short copy/paste task prompts derived from canonical workflows. " + "Full policy remains in the cited workflow files — not duplicated here.
" + f"{''.join(cards)}" + f"{PROMPT_PAGE_SCRIPT}" + ) + from webui.layout import render_page + + return render_page(title="Prompts", body_html=body) + + +def _render_prompt_card(entry: PromptEntry) -> str: + hash_short = ( + f"{_escape(entry.workflow_hash[:12])}"
+ if entry.workflow_hash
+ else "n/a"
+ )
+ task_mode = (
+ f"{_escape(entry.task_mode)}"
+ if entry.task_mode
+ else "n/a"
+ )
+ note = (
+ f''
+ if entry.source_note
+ else ""
+ )
+ prompt_id = f"prompt-{entry.slug}"
+ return (
+ f'{_escape(entry.prompt_text)}'
+ f'"
+ f"{note}"
+ "