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 %}