feat: add canonical workflow prompt library to web UI (Closes #428)
Expose short copy/paste operator prompts on /prompts derived from canonical workflow files with workflow path and SHA-256 hash. Adds JSON export at /api/prompts, copy buttons, and tests. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+11
-2
@@ -39,7 +39,8 @@ Optional environment variables:
|
|||||||
| `/projects` | Project registry list (#427) |
|
| `/projects` | Project registry list (#427) |
|
||||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
| `/projects/{id}` | Project detail + onboarding checklist |
|
||||||
| `/api/projects` | JSON registry export |
|
| `/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) |
|
| `/runtime` | Stub — MCP runtime health (#430) |
|
||||||
| `/audit` | Stub — report audit paste (#431) |
|
| `/audit` | Stub — report audit paste (#431) |
|
||||||
| `/worktrees` | Stub — hygiene dashboard (#432) |
|
| `/worktrees` | Stub — hygiene dashboard (#432) |
|
||||||
@@ -60,8 +61,16 @@ or credentials.
|
|||||||
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
|
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
|
||||||
`prgs-reviewer`, and `prgs-reconciler` profiles.
|
`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
|
## Tests
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
```
|
```
|
||||||
@@ -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()
|
||||||
@@ -30,12 +30,17 @@ class TestWebuiSkeleton(unittest.TestCase):
|
|||||||
self.assertIn("Read-only MVP", response.text)
|
self.assertIn("Read-only MVP", response.text)
|
||||||
|
|
||||||
def test_route_stubs_render(self):
|
def test_route_stubs_render(self):
|
||||||
for path in ("/prompts", "/runtime", "/audit"):
|
for path in ("/runtime", "/audit"):
|
||||||
with self.subTest(path=path):
|
with self.subTest(path=path):
|
||||||
response = self.client.get(path)
|
response = self.client.get(path)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn("child issue", response.text.lower())
|
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):
|
def test_projects_is_implemented(self):
|
||||||
response = self.client.get("/projects")
|
response = self.client.get("/projects")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|||||||
+8
-4
@@ -12,6 +12,8 @@ from starlette.routing import Route
|
|||||||
from webui.layout import render_page
|
from webui.layout import render_page
|
||||||
from webui.project_registry import find_project, load_registry, registry_to_dict
|
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.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"})
|
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||||
|
|
||||||
@@ -80,10 +82,11 @@ async def api_projects(_request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
|
|
||||||
async def prompts(_request: Request) -> HTMLResponse:
|
async def prompts(_request: Request) -> HTMLResponse:
|
||||||
return _stub_page(
|
return HTMLResponse(render_prompts_page())
|
||||||
"Prompts",
|
|
||||||
"Prompt library will surface canonical workflows from skills/llm-project-workflow/.",
|
|
||||||
)
|
async def api_prompts(_request: Request) -> JSONResponse:
|
||||||
|
return JSONResponse(library_to_dict())
|
||||||
|
|
||||||
|
|
||||||
async def runtime(_request: Request) -> HTMLResponse:
|
async def runtime(_request: Request) -> HTMLResponse:
|
||||||
@@ -134,6 +137,7 @@ def create_app() -> Starlette:
|
|||||||
Route("/projects/{project_id}", project_detail, methods=["GET"]),
|
Route("/projects/{project_id}", project_detail, methods=["GET"]),
|
||||||
Route("/api/projects", api_projects, methods=["GET"]),
|
Route("/api/projects", api_projects, methods=["GET"]),
|
||||||
Route("/prompts", prompts, methods=["GET"]),
|
Route("/prompts", prompts, methods=["GET"]),
|
||||||
|
Route("/api/prompts", api_prompts, methods=["GET"]),
|
||||||
Route("/runtime", runtime, methods=["GET"]),
|
Route("/runtime", runtime, methods=["GET"]),
|
||||||
Route("/audit", audit, methods=["GET"]),
|
Route("/audit", audit, methods=["GET"]),
|
||||||
Route("/worktrees", worktrees, methods=["GET"]),
|
Route("/worktrees", worktrees, methods=["GET"]),
|
||||||
|
|||||||
+32
-1
@@ -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(
|
nav_links = "".join(
|
||||||
f'<a href="{href}">{label}</a>' for href, label in NAV_ITEMS
|
f'<a href="{href}">{label}</a>' 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 li {{ margin-bottom: 0.85rem; }}
|
||||||
ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }}
|
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); }}
|
||||||
</style>
|
</style>
|
||||||
|
{extra_head}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Canonical workflow prompt library for the internal web UI (#428)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_WORKFLOW_ROOT = Path("skills/llm-project-workflow/workflows")
|
||||||
|
|
||||||
|
_DEFAULT_PROMPT_RE = re.compile(
|
||||||
|
r"\*\*Default task prompt:\*\*\s*\n+>\s*(.+?)(?=\n\n|\nDo not improvise)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
_FRONTMATTER_TASK_MODE_RE = re.compile(r"^task_mode:\s*(\S+)", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PromptEntry:
|
||||||
|
slug: str
|
||||||
|
label: str
|
||||||
|
prompt_text: str
|
||||||
|
workflow_path: str
|
||||||
|
task_mode: str | None
|
||||||
|
workflow_hash: str | None
|
||||||
|
source_note: str
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_root() -> Path:
|
||||||
|
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
|
||||||
|
if override:
|
||||||
|
return Path(override).resolve()
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_file(path: str) -> Path:
|
||||||
|
return _repo_root() / path
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_hex(content: str) -> str:
|
||||||
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_workflow(path: str) -> tuple[str, str]:
|
||||||
|
file_path = _workflow_file(path)
|
||||||
|
text = file_path.read_text(encoding="utf-8")
|
||||||
|
return text, _sha256_hex(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_default_prompt(markdown: str) -> str | None:
|
||||||
|
match = _DEFAULT_PROMPT_RE.search(markdown)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
lines = [line.strip() for line in match.group(1).splitlines()]
|
||||||
|
return " ".join(line for line in lines if line)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_task_mode(markdown: str) -> str | None:
|
||||||
|
match = _FRONTMATTER_TASK_MODE_RE.search(markdown)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_from_workflow(
|
||||||
|
*,
|
||||||
|
slug: str,
|
||||||
|
label: str,
|
||||||
|
workflow_path: str,
|
||||||
|
prompt_override: str | None = None,
|
||||||
|
source_note: str = "",
|
||||||
|
) -> PromptEntry:
|
||||||
|
markdown, digest = _read_workflow(workflow_path)
|
||||||
|
prompt_text = prompt_override or _extract_default_prompt(markdown)
|
||||||
|
if not prompt_text:
|
||||||
|
raise ValueError(f"No default task prompt found in {workflow_path}")
|
||||||
|
return PromptEntry(
|
||||||
|
slug=slug,
|
||||||
|
label=label,
|
||||||
|
prompt_text=prompt_text,
|
||||||
|
workflow_path=workflow_path,
|
||||||
|
task_mode=_extract_task_mode(markdown),
|
||||||
|
workflow_hash=digest,
|
||||||
|
source_note=source_note,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _static_entry(
|
||||||
|
*,
|
||||||
|
slug: str,
|
||||||
|
label: str,
|
||||||
|
prompt_text: str,
|
||||||
|
workflow_path: str,
|
||||||
|
source_note: str,
|
||||||
|
) -> PromptEntry:
|
||||||
|
path = _workflow_file(workflow_path)
|
||||||
|
digest = _sha256_hex(path.read_text(encoding="utf-8")) if path.is_file() else None
|
||||||
|
markdown = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||||
|
return PromptEntry(
|
||||||
|
slug=slug,
|
||||||
|
label=label,
|
||||||
|
prompt_text=prompt_text,
|
||||||
|
workflow_path=workflow_path,
|
||||||
|
task_mode=_extract_task_mode(markdown) if markdown else None,
|
||||||
|
workflow_hash=digest,
|
||||||
|
source_note=source_note,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_prompt_library() -> tuple[PromptEntry, ...]:
|
||||||
|
"""Load operator prompts derived from canonical workflows."""
|
||||||
|
entries = (
|
||||||
|
_entry_from_workflow(
|
||||||
|
slug="review-pr",
|
||||||
|
label="Review PR",
|
||||||
|
workflow_path=str(_WORKFLOW_ROOT / "review-merge-pr.md"),
|
||||||
|
),
|
||||||
|
_entry_from_workflow(
|
||||||
|
slug="work-issue",
|
||||||
|
label="Work issue",
|
||||||
|
workflow_path=str(_WORKFLOW_ROOT / "work-issue.md"),
|
||||||
|
),
|
||||||
|
_entry_from_workflow(
|
||||||
|
slug="create-issue",
|
||||||
|
label="Create issue",
|
||||||
|
workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
|
||||||
|
),
|
||||||
|
_static_entry(
|
||||||
|
slug="comment-issue",
|
||||||
|
label="Comment issue",
|
||||||
|
workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
|
||||||
|
prompt_text=(
|
||||||
|
"Comment on the target Gitea issue only if exact comment_issue "
|
||||||
|
"capability is proven. Load the canonical create-issue workflow "
|
||||||
|
"first and follow §16 (comment-on-existing issue rule). Include "
|
||||||
|
"specific evidence; do not duplicate existing comments."
|
||||||
|
),
|
||||||
|
source_note="Derived from create-issue.md §16; full policy remains in the workflow file.",
|
||||||
|
),
|
||||||
|
_static_entry(
|
||||||
|
slug="cleanup",
|
||||||
|
label="PR queue cleanup",
|
||||||
|
workflow_path=str(_WORKFLOW_ROOT / "pr-queue-cleanup.md"),
|
||||||
|
prompt_text=(
|
||||||
|
"Run PR-only queue cleanup: build a complete open-PR inventory "
|
||||||
|
"with pagination proof, select exactly one eligible PR, dispatch "
|
||||||
|
"one canonical review for that PR, then stop after any terminal "
|
||||||
|
"review mutation. Load pr-queue-cleanup.md and review-merge-pr.md "
|
||||||
|
"before any mutation."
|
||||||
|
),
|
||||||
|
source_note="Derived from pr-queue-cleanup.md; composes with review-merge-pr.md.",
|
||||||
|
),
|
||||||
|
_entry_from_workflow(
|
||||||
|
slug="audit",
|
||||||
|
label="Reconciliation audit",
|
||||||
|
workflow_path=str(_WORKFLOW_ROOT / "reconcile-landed-pr.md"),
|
||||||
|
),
|
||||||
|
_static_entry(
|
||||||
|
slug="onboarding",
|
||||||
|
label="Project onboarding",
|
||||||
|
workflow_path="docs/webui-local-dev.md",
|
||||||
|
prompt_text=(
|
||||||
|
"Start a new MCP Control Plane session: call mcp_get_control_plane_guide, "
|
||||||
|
"prove identity and task capability, then complete the onboarding checklist "
|
||||||
|
"for this project at /projects. Keep Gitea, MCP tools, and canonical "
|
||||||
|
"workflows as the source of truth."
|
||||||
|
),
|
||||||
|
source_note="Operator onboarding; checklist details live in the project registry UI.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_to_dict(entry: PromptEntry) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"slug": entry.slug,
|
||||||
|
"label": entry.label,
|
||||||
|
"prompt_text": entry.prompt_text,
|
||||||
|
"workflow_path": entry.workflow_path,
|
||||||
|
"task_mode": entry.task_mode,
|
||||||
|
"workflow_hash": entry.workflow_hash,
|
||||||
|
"source_note": entry.source_note,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def library_to_dict() -> dict[str, Any]:
|
||||||
|
entries = load_prompt_library()
|
||||||
|
return {
|
||||||
|
"count": len(entries),
|
||||||
|
"prompts": [prompt_to_dict(entry) for entry in entries],
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""HTML views for the prompt library (#428)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
|
||||||
|
from webui.prompt_library import PromptEntry, load_prompt_library
|
||||||
|
|
||||||
|
|
||||||
|
def _escape(text: str) -> str:
|
||||||
|
return html.escape(text, quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def render_prompts_page() -> str:
|
||||||
|
entries = load_prompt_library()
|
||||||
|
cards: list[str] = []
|
||||||
|
for entry in entries:
|
||||||
|
cards.append(_render_prompt_card(entry))
|
||||||
|
body = (
|
||||||
|
"<h2>Prompt library</h2>"
|
||||||
|
"<p>Short copy/paste task prompts derived from canonical workflows. "
|
||||||
|
"Full policy remains in the cited workflow files — not duplicated here.</p>"
|
||||||
|
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"<code>{_escape(entry.workflow_hash[:12])}</code>"
|
||||||
|
if entry.workflow_hash
|
||||||
|
else "<span class=\"muted\">n/a</span>"
|
||||||
|
)
|
||||||
|
task_mode = (
|
||||||
|
f"<code>{_escape(entry.task_mode)}</code>"
|
||||||
|
if entry.task_mode
|
||||||
|
else "<span class=\"muted\">n/a</span>"
|
||||||
|
)
|
||||||
|
note = (
|
||||||
|
f'<p class="meta">{_escape(entry.source_note)}</p>'
|
||||||
|
if entry.source_note
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
prompt_id = f"prompt-{entry.slug}"
|
||||||
|
return (
|
||||||
|
f'<section class="prompt-card" id="{_escape(entry.slug)}">'
|
||||||
|
f"<h3>{_escape(entry.label)}</h3>"
|
||||||
|
f'<p class="meta">Workflow: <code>{_escape(entry.workflow_path)}</code> · '
|
||||||
|
f"task_mode: {task_mode} · sha256: {hash_short}</p>"
|
||||||
|
f'<pre class="prompt-text" id="{prompt_id}">{_escape(entry.prompt_text)}</pre>'
|
||||||
|
f'<button type="button" class="copy-btn" data-copy-target="{prompt_id}">'
|
||||||
|
"Copy prompt</button>"
|
||||||
|
f"{note}"
|
||||||
|
"</section>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROMPT_PAGE_SCRIPT = """
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.copy-btn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const targetId = btn.getAttribute('data-copy-target');
|
||||||
|
const node = document.getElementById(targetId);
|
||||||
|
if (!node) return;
|
||||||
|
const text = node.textContent || '';
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
const prior = btn.textContent;
|
||||||
|
btn.textContent = 'Copied';
|
||||||
|
setTimeout(() => { btn.textContent = prior; }, 1200);
|
||||||
|
} catch (_err) {
|
||||||
|
btn.textContent = 'Copy failed';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
Reference in New Issue
Block a user