Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/powercontext/server/dashboard/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from __future__ import annotations

import asyncio
import base64
import json
from contextlib import suppress
from typing import Any
Expand All @@ -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}
Expand All @@ -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(
Expand Down Expand Up @@ -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,
}
59 changes: 59 additions & 0 deletions src/powercontext/server/dashboard/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
29 changes: 29 additions & 0 deletions src/powercontext/server/dashboard/labels.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions src/powercontext/server/dashboard/labels.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "其他范围",
Expand Down
15 changes: 14 additions & 1 deletion src/powercontext/server/dashboard/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
{% if errors.get('scopes') %}<p class="small mt-2">{{ t.scope_list_unavailable }}</p>{% endif %}
</form>
<ul class="navbar-nav w-100" aria-label="{{ t.navigation }}">
{% 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 %}
<li class="nav-item {% if active %}active{% endif %}"><a href="{{ link(item) }}" class="nav-link {% if active %}active{% endif %}" {% if active %}aria-current="page"{% endif %}>
{{ icon(item) }}<span class="nav-link-title">{{ t[item] }}</span>
Expand Down
2 changes: 1 addition & 1 deletion src/powercontext/server/dashboard/templates/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

{% macro icon(name) %}
<span class="nav-symbol nav-link-icon" aria-hidden="true">
<img class="w-100 h-100" src="/dashboard/static/vendor/icons/{{ {'home':'home','handoff':'arrows-exchange','notes':'notes','methods':'book','usage':'chart-bar'}[name] }}.svg" alt="">
<img class="w-100 h-100" src="/dashboard/static/vendor/icons/{{ {'home':'home','handoff':'arrows-exchange','notes':'notes','methods':'book','topics':'book','prompts':'notes','usage':'chart-bar'}[name] }}.svg" alt="">
</span>
{% endmacro %}

Expand Down
35 changes: 35 additions & 0 deletions src/powercontext/server/dashboard/templates/prompts.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!--
Copyright (c) 2026 OceanBase.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
{% from 'components/page-heading.html' import page_heading %}
Comment thread
Teingi marked this conversation as resolved.
{{ page_heading(t.prompts, t.prompts_subtitle) }}

{% if errors.get('prompts') %}
{{ read_error(errors.prompts, t.prompts) }}
{% elif not data.prompts %}
<section class="card"><div class="empty"><h2>{{ t.prompt_empty }}</h2><p class="empty-subtitle text-secondary">{{ t.prompt_empty_body }}</p></div></section>
{% else %}
<div class="row row-cards">
{% for prompt in data.prompts %}
<section class="col-12 col-xl-6"><article class="card h-100">
<div class="card-header"><div><h2 class="card-title mb-1">{{ prompt.prompt_key }}</h2><div class="text-secondary small">{{ t.prompt_status }}: {{ prompt.status }}</div></div><span class="badge bg-blue-lt">{{ t.prompt_mode }}: {{ t.prompt_custom if prompt.mode == 'custom' else t.prompt_auto }}</span></div>
<div class="card-body">
<dl class="row mb-3"><dt class="col-sm-4">{{ t.prompt_revision }}</dt><dd class="col-sm-8">{{ prompt.artifact.revision if prompt.artifact else t.prompt_auto }}</dd><dt class="col-sm-4">{{ t.prompt_demonstrations }}</dt><dd class="col-sm-8">{{ prompt.effective.demonstrations|length if prompt.effective else 0 }}</dd></dl>
{% if prompt.effective %}<h3 class="h4">{{ t.prompt_instructions }}</h3><pre class="page-body-pre text-break">{{ prompt.effective.instructions }}</pre>{% elif prompt.builtin %}<h3 class="h4">{{ t.prompt_instructions }}</h3><pre class="page-body-pre text-break">{{ prompt.builtin.instructions }}</pre>{% endif %}
</div>
</article></section>
{% endfor %}
</div>
{% endif %}
Loading
Loading