diff --git a/src/powercontext/server/dashboard/api.py b/src/powercontext/server/dashboard/api.py
index 6bed0ecf5..2aaf791b4 100644
--- a/src/powercontext/server/dashboard/api.py
+++ b/src/powercontext/server/dashboard/api.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import asyncio
+import base64
import json
from contextlib import suppress
from typing import Any
@@ -26,9 +27,13 @@
from fastapi import Request
from pydantic import ValidationError
+from powercontext.artifacts import ArtifactRef
from powercontext.builtin.artifacts.experience import ExperienceContent
from powercontext.builtin.artifacts.handoff.models import HandoffContent
from powercontext.builtin.artifacts.skill import SkillContent
+from powercontext.builtin.artifacts.topic_memory import TopicMemoryBrowseCursor
+from powercontext.builtin.runtime.models import GetTopicMemoryRequest
+from powercontext.errors import ArtifactNotFoundError
from powercontext.server.dashboard.session import authentication_headers
CONTENT_MODELS = {"handoff": HandoffContent, "experience": ExperienceContent, "skill": SkillContent}
@@ -50,6 +55,7 @@ class DashboardAPI:
"""Use an in-process HTTP transport with the incoming user's credentials."""
def __init__(self, request: Request) -> None:
+ self.app = request.app
headers = authentication_headers(request.scope)
headers = {key: value for key, value in headers.items() if key in {"authorization", "cookie"}}
self.client = httpx.AsyncClient(
@@ -143,3 +149,76 @@ async def load(item: dict[str, Any]) -> dict[str, Any]:
"items": list(await asyncio.gather(*(load(item) for item in page["items"]))),
"next_cursor": page["next_cursor"],
}
+
+ async def topic_memory_search(self, scope: str, query: str) -> dict[str, Any]:
+ return await self.read("/v1/topic-memory/search", {"scope_id": scope, "query": query})
+
+ async def prompt_configuration(self, scope: str, key: str) -> dict[str, Any]:
+ return await self.read(f"/v1/scopes/{segment(scope)}/prompts/{segment(key)}")
+
+ async def topic_memory_get(self, scope: str, artifact: dict[str, Any]) -> dict[str, Any]:
+ application = getattr(self.app.state, "application", None)
+ if application is None:
+ raise ReadError(503, "service_unavailable")
+ try:
+ value = await application.topic_memory.for_scope(scope).get(
+ GetTopicMemoryRequest(artifact=ArtifactRef.model_validate(artifact))
+ )
+ except ArtifactNotFoundError as error:
+ raise ReadError(404, "not_found") from error
+ except ValueError as error:
+ raise ReadError(422, "invalid_request") from error
+ return {
+ "artifact": value.topic.as_ref().model_dump(mode="json"),
+ "title": value.topic.content.title,
+ "summary": value.topic.content.summary,
+ "detail": value.topic.content.detail,
+ "source_refs": [
+ {"name": source.source_type, "source_id": source.source_id} for source in value.topic.lineage.sources
+ ],
+ "is_current": value.is_current,
+ "current_artifact": value.current_artifact.model_dump(mode="json"),
+ }
+
+ @staticmethod
+ def _decode_topic_cursor(value: str) -> TopicMemoryBrowseCursor:
+ try:
+ padding = "=" * (-len(value) % 4)
+ payload = base64.urlsafe_b64decode(value + padding)
+ return TopicMemoryBrowseCursor.model_validate_json(payload)
+ except (ValueError, TypeError, ValidationError) as error:
+ raise ReadError(422, "invalid_request") from error
+
+ @staticmethod
+ def _encode_topic_cursor(value: TopicMemoryBrowseCursor) -> str:
+ payload = json.dumps(value.model_dump(mode="json"), separators=(",", ":")).encode()
+ return base64.urlsafe_b64encode(payload).decode().rstrip("=")
+
+ async def topic_memory_browse(self, scope: str, *, cursor: str | None = None, limit: int = 50) -> dict[str, Any]:
+ application = getattr(self.app.state, "application", None)
+ if application is None:
+ raise ReadError(503, "service_unavailable")
+ after = self._decode_topic_cursor(cursor) if cursor else None
+ items = await application.topic_memory.for_scope(scope).browse(limit=limit + 1, after=after)
+ has_next = len(items) > limit
+ items = items[:limit]
+ next_cursor = None
+ if has_next and items:
+ last = items[-1]
+ next_cursor = self._encode_topic_cursor(
+ TopicMemoryBrowseCursor(
+ published_at=last.published_at,
+ artifact_id=last.artifact_ref.artifact_id,
+ revision=last.artifact_ref.revision,
+ )
+ )
+ return {
+ "items": [
+ {
+ **item.model_dump(mode="json"),
+ "artifact": item.artifact_ref.model_dump(mode="json"),
+ }
+ for item in items
+ ],
+ "next_cursor": next_cursor,
+ }
diff --git a/src/powercontext/server/dashboard/content.py b/src/powercontext/server/dashboard/content.py
index 6e9dff421..fd468d9f7 100644
--- a/src/powercontext/server/dashboard/content.py
+++ b/src/powercontext/server/dashboard/content.py
@@ -159,5 +159,64 @@ async def load_content(api: DashboardAPI, request: Request, ctx: dict[str, Any])
await load_collection(api, request, ctx, ctx["method_kind"])
elif page == "usage":
await load_stats(api, ctx)
+ elif page == "topics":
+ await load_topics(api, request, ctx)
+ elif page == "prompts":
+ await load_prompts(api, ctx)
elif page in RECORDS:
await load_record(api, request, ctx)
+
+
+async def load_topics(api: DashboardAPI, request: Request, ctx: dict[str, Any]) -> None:
+ """Load Topic Memory browse/search results and an exact selected revision."""
+ query = ctx["artifact_query"]
+ if query:
+ try:
+ result = await api.topic_memory_search(ctx["scope"], query)
+ for hit in result["hits"]:
+ try:
+ record = await api.topic_memory_get(ctx["scope"], hit["artifact"])
+ ctx["data"]["topic_memory"].append({**hit, **record, "is_current": record["is_current"]})
+ except ReadError as error:
+ ctx["errors"].setdefault("topic_memory", error)
+ except ReadError as error:
+ ctx["errors"]["topic_memory"] = error
+ else:
+ try:
+ page = await api.topic_memory_browse(ctx["scope"], cursor=ctx["topic_cursor"])
+ ctx["data"]["topic_memory"] = page["items"]
+ ctx["topic_memory_pager"] = cursor_links(request, ctx, "topic", page["next_cursor"])
+ except ReadError as error:
+ ctx["errors"]["topic_memory"] = error
+ if ctx["topic_artifact"] and ctx["topic_revision"]:
+ try:
+ ctx["data"]["topic_memory_selected"] = await api.topic_memory_get(
+ ctx["scope"],
+ {
+ "family": "topic-memory",
+ "artifact_id": ctx["topic_artifact"],
+ "revision": int(ctx["topic_revision"]),
+ },
+ )
+ except ValueError:
+ ctx["errors"]["topic_memory_selected"] = ReadError(422, "invalid_request")
+ except ReadError as error:
+ ctx["errors"]["topic_memory_selected"] = error
+
+
+async def load_prompts(api: DashboardAPI, ctx: dict[str, Any]) -> None:
+ """Load the scoped Prompt configurations exposed by the Prompt Dashboard."""
+ keys = (
+ "memory.extract",
+ "memory.rerank",
+ "experience.incubate",
+ "experience.generate",
+ "skill.generate",
+ "handoff.generate",
+ )
+ for key in keys:
+ try:
+ value = await api.prompt_configuration(ctx["scope"], key)
+ ctx["data"]["prompts"].append(value)
+ except ReadError as error:
+ ctx["errors"].setdefault("prompts", error)
diff --git a/src/powercontext/server/dashboard/labels.en.json b/src/powercontext/server/dashboard/labels.en.json
index 4d355fa4f..b49df0e07 100644
--- a/src/powercontext/server/dashboard/labels.en.json
+++ b/src/powercontext/server/dashboard/labels.en.json
@@ -4,6 +4,35 @@
"notes": "Memories",
"methods": "Experiences & Skills",
"usage": "Usage",
+ "topics": "Topic Memory",
+ "prompts": "Prompts",
+ "prompts_subtitle": "Review scoped operational guidance and immutable revisions",
+ "prompt_status": "Status",
+ "prompt_mode": "Mode",
+ "prompt_revision": "Revision",
+ "prompt_instructions": "Effective instructions",
+ "prompt_demonstrations": "Demonstrations",
+ "prompt_auto": "Auto",
+ "prompt_custom": "Custom",
+ "prompt_supported": "Customization available",
+ "prompt_disabled": "Disabled",
+ "prompt_unsupported": "Externally managed",
+ "prompt_empty": "No Prompt configuration",
+ "prompt_empty_body": "Prompt configurations are read from the active Runtime for this Scope.",
+ "topic_memory": "Topic Memory",
+ "topics_subtitle": "Browse durable Topic Memory and inspect an exact revision",
+ "topic_inventory": "Topic Memory inventory",
+ "select_topic": "Select a topic to inspect its exact revision",
+ "select_topic_hint": "Full detail, publication state and source references appear here.",
+ "current_revision": "Current revision",
+ "historical_revision": "Historical revision",
+ "view_current_revision": "View current revision",
+ "full_detail": "Full detail",
+ "source_references": "Source references",
+ "no_sources": "No direct Source references",
+ "search_topic_memory": "Search Topic Memory",
+ "topic_memory_empty": "No matching topics",
+ "topic_memory_empty_body": "No active Topic Memory matched this query.",
"experience": "Experience",
"skill": "Skill",
"other_scopes": "Other scopes",
diff --git a/src/powercontext/server/dashboard/labels.json b/src/powercontext/server/dashboard/labels.json
index 6faa4f7b5..98fce8173 100644
--- a/src/powercontext/server/dashboard/labels.json
+++ b/src/powercontext/server/dashboard/labels.json
@@ -4,6 +4,35 @@
"notes": "记忆",
"methods": "经验与技能",
"usage": "用量",
+ "topics": "主题记忆",
+ "prompts": "提示词",
+ "prompts_subtitle": "查看当前 Scope 的操作提示词和不可变版本",
+ "prompt_status": "状态",
+ "prompt_mode": "模式",
+ "prompt_revision": "版本",
+ "prompt_instructions": "生效提示词",
+ "prompt_demonstrations": "案例",
+ "prompt_auto": "自动",
+ "prompt_custom": "自定义",
+ "prompt_supported": "支持自定义",
+ "prompt_disabled": "未启用",
+ "prompt_unsupported": "由外部组件管理",
+ "prompt_empty": "暂无提示词配置",
+ "prompt_empty_body": "提示词配置由当前 Scope 的 Runtime 提供。",
+ "topic_memory": "主题记忆",
+ "topics_subtitle": "浏览持久主题记忆并查看精确版本",
+ "topic_inventory": "主题记忆列表",
+ "select_topic": "请选择一个主题以查看其精确版本",
+ "select_topic_hint": "这里会显示完整详情、发布状态和来源引用。",
+ "current_revision": "当前版本",
+ "historical_revision": "历史版本",
+ "view_current_revision": "查看当前版本",
+ "full_detail": "完整详情",
+ "source_references": "来源引用",
+ "no_sources": "没有直接来源引用",
+ "search_topic_memory": "搜索主题记忆",
+ "topic_memory_empty": "没有匹配的主题",
+ "topic_memory_empty_body": "当前内容范围没有匹配此查询的主题记忆。",
"experience": "经验",
"skill": "技能",
"other_scopes": "其他范围",
diff --git a/src/powercontext/server/dashboard/routes.py b/src/powercontext/server/dashboard/routes.py
index 833919885..e2749db85 100644
--- a/src/powercontext/server/dashboard/routes.py
+++ b/src/powercontext/server/dashboard/routes.py
@@ -32,7 +32,7 @@
ROOT = Path(__file__).parent
LABELS = CATALOGS["zh"]
PARENTS = {"handoff-detail": "handoff", "experience": "methods", "skill": "methods"}
-PAGES = {"home", "handoff", "notes", "methods", "usage", "entry", *RECORDS}
+PAGES = {"home", "handoff", "notes", "methods", "topics", "prompts", "usage", "entry", *RECORDS}
ENV = Environment(
loader=FileSystemLoader(ROOT / "templates"), autoescape=select_autoescape(), undefined=StrictUndefined
)
@@ -80,6 +80,11 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s
"experience_cursor",
"skill_cursor",
"q",
+ "topic_q",
+ "topic_artifact",
+ "topic_revision",
+ "topic_cursor",
+ "topic_history",
"notes_page",
"skill_page",
"experience_history",
@@ -129,6 +134,10 @@ def initial_context(request: Request, page: str) -> dict[str, Any]:
"period": request.query_params.get("period", "7d"),
"method_kind": method_kind,
"search_query": request.query_params.get("q", "").strip() or None,
+ "artifact_query": request.query_params.get("topic_q", "").strip() or None,
+ "topic_artifact": request.query_params.get("topic_artifact"),
+ "topic_revision": request.query_params.get("topic_revision"),
+ "topic_cursor": request.query_params.get("topic_cursor"),
"search_limited": False,
"data": {
"title": "PowerContext",
@@ -137,6 +146,9 @@ def initial_context(request: Request, page: str) -> dict[str, Any]:
"handoff": None,
"experience": None,
"skill": None,
+ "topic_memory": [],
+ "topic_memory_selected": None,
+ "prompts": [],
},
"scopes": [],
"scope_descriptor": None,
@@ -154,6 +166,7 @@ def initial_context(request: Request, page: str) -> dict[str, Any]:
"related_sources": [],
"source_record": None,
"source": None,
+ "topic_memory_pager": None,
}
ctx["link"] = links(request, ctx)
return ctx
diff --git a/src/powercontext/server/dashboard/templates/components/navigation.html b/src/powercontext/server/dashboard/templates/components/navigation.html
index 0c3fcc4ce..d3e31e456 100644
--- a/src/powercontext/server/dashboard/templates/components/navigation.html
+++ b/src/powercontext/server/dashboard/templates/components/navigation.html
@@ -34,7 +34,7 @@
{% if errors.get('scopes') %}
{{ t.scope_list_unavailable }}
{% endif %}
- {% for item in ['home', 'handoff', 'notes', 'methods', 'usage'] %}
+ {% for item in ['home', 'handoff', 'notes', 'methods', 'topics', 'prompts', 'usage'] %}
{% set active = page == item or parent_page == item %}
-
{{ icon(item) }}{{ t[item] }}
diff --git a/src/powercontext/server/dashboard/templates/macros.html b/src/powercontext/server/dashboard/templates/macros.html
index 6cbc6f7d7..c346552ba 100644
--- a/src/powercontext/server/dashboard/templates/macros.html
+++ b/src/powercontext/server/dashboard/templates/macros.html
@@ -16,7 +16,7 @@
{% macro icon(name) %}
-
+
{% endmacro %}
diff --git a/src/powercontext/server/dashboard/templates/prompts.html b/src/powercontext/server/dashboard/templates/prompts.html
new file mode 100644
index 000000000..9608ef0d0
--- /dev/null
+++ b/src/powercontext/server/dashboard/templates/prompts.html
@@ -0,0 +1,35 @@
+
+{% from 'components/page-heading.html' import page_heading %}
+{{ page_heading(t.prompts, t.prompts_subtitle) }}
+
+{% if errors.get('prompts') %}
+ {{ read_error(errors.prompts, t.prompts) }}
+{% elif not data.prompts %}
+ {{ t.prompt_empty }}
{{ t.prompt_empty_body }}
+{% else %}
+
+ {% for prompt in data.prompts %}
+
+
+
+
- {{ t.prompt_revision }}
- {{ prompt.artifact.revision if prompt.artifact else t.prompt_auto }}
- {{ t.prompt_demonstrations }}
- {{ prompt.effective.demonstrations|length if prompt.effective else 0 }}
+ {% if prompt.effective %}
{{ t.prompt_instructions }}
{{ prompt.effective.instructions }}{% elif prompt.builtin %}
{{ t.prompt_instructions }}
{{ prompt.builtin.instructions }}{% endif %}
+
+
+ {% endfor %}
+
+{% endif %}
diff --git a/src/powercontext/server/dashboard/templates/topics.html b/src/powercontext/server/dashboard/templates/topics.html
new file mode 100644
index 000000000..ce766f916
--- /dev/null
+++ b/src/powercontext/server/dashboard/templates/topics.html
@@ -0,0 +1,46 @@
+
+{% from 'components/page-heading.html' import page_heading %}
+{% from 'components/pagination.html' import pagination %}
+{{ page_heading(t.topic_memory, t.topics_subtitle) }}
+
+
+
+
+
+ {% if errors.get('topic_memory') %}{{ read_error(errors.topic_memory, t.topic_memory) }}
+ {% elif data.topic_memory %}
+ {% else %}
{{ t.topic_memory_empty }}
{{ t.topic_memory_empty_body }}
{% endif %}
+
+ {{ pagination(topic_memory_pager, t) }}
+
+
+ {% if errors.get('topic_memory_selected') %}{{ read_error(errors.topic_memory_selected, t.topic_memory) }}
+ {% elif data.topic_memory_selected %}{% set topic = data.topic_memory_selected %}
+
{{ topic.title }}
topic-memory/{{ topic.artifact.artifact_id }}@{{ topic.artifact.revision }}{{ t.current_revision if topic.is_current else t.historical_revision }}
+ {% if not topic.is_current %}
{{ t.view_current_revision }}
{% endif %}
+
{{ topic.summary }}
{{ t.full_detail }}
{{ topic.detail }}
+
{{ t.source_references }}
{% if topic.source_refs %}
{% for source in topic.source_refs %}{{ source.name }}/{{ source.source_id }} {% endfor %}
{% else %}
{{ t.no_sources }}
{% endif %}
+ {% else %}
{{ t.select_topic }}
{{ t.select_topic_hint }}
{% endif %}
+
+
diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py
index f804e87c3..ed17f51db 100644
--- a/tests/test_dashboard.py
+++ b/tests/test_dashboard.py
@@ -81,6 +81,23 @@ def test_personal_dashboard_opens_without_models_or_saved_content(dashboard: Tes
assert "Use uv for dependency management." in dashboard.get("/dashboard/notes").text
+def test_topic_dashboard_opens_without_content(dashboard: TestClient) -> None:
+ topics = dashboard.get("/dashboard/topics", params={"lang": "en"})
+ assert topics.status_code == 200
+ assert "Topic Memory" in topics.text
+ assert "Prompt configuration" not in topics.text
+ assert "Artifacts" not in topics.text
+
+
+def test_prompt_dashboard_opens_without_profile_page(dashboard: TestClient) -> None:
+ prompts = dashboard.get("/dashboard/prompts", params={"lang": "en"})
+ profile = dashboard.get("/dashboard/profile", params={"lang": "en"})
+ assert prompts.status_code == 200
+ assert "Prompts" in prompts.text
+ assert profile.status_code == 404
+ assert "Profile" not in prompts.text
+
+
def test_dashboard_favicons_use_square_viewports(dashboard: TestClient) -> None:
home = dashboard.get("/")
icons = re.findall(r']*href="([^"]+)"', home.text)