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
2 changes: 1 addition & 1 deletion docs/en/development/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ The HTTP API specification defines request fields and limits.
| Scope selection | `GET /v1/scopes`, `GET /v1/scopes/default`, `GET /v1/scopes/{scope_id}` | Treat defaults, explicit selection, hierarchy and readable scopes separately |
| Memory collection and text | `POST /v1/atomic-memory/list`, `POST /v1/memory/search`, exact Atomic Memory revision and state reads | Preserve Atomic Memory identities, lifecycle filters, the selected search mode and hit evidence; return up to 50 matches |
| Handoff collection and text | `GET /v1/scopes/{scope_id}/artifacts/handoff` and exact revision reads | Retain cursors; list order does not imply chronological order |
| Experience collection and text | `GET /v1/scopes/{scope_id}/artifacts/experience`, `POST /v1/experience/get` | Dashboard provides paged browsing; see [Artifact search](../docs/workflows/search-artifacts.md) for Family relevance retrieval |
| Experience collection and text | `GET /v1/scopes/{scope_id}/artifacts/experience`, `POST /v1/experience/get`, `POST /v1/scopes/{scope_id}/artifacts/experience/search` | Dashboard provides paged browsing and Family relevance search; search covers approved active revisions only, returns up to 200 matches, and suggests a narrower query at that limit |
| Skill collection and text | `POST /v1/skill/library`, `POST /v1/skill/get` | Library queries return up to 200 entries; suggest a narrower query at the limit and preserve source identities |
| Source material | `GET /v1/scopes/{scope_id}/sources/{source_type}/{source_id}` | Verify the source's relationship to the record; `content` is currently the source type with readable text |
| Usage | `POST /v1/stats` | Read the current scope with `exact` and use Server reporting periods and statistical definitions |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/development/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Home 的记忆、交接、经验和技能可以独立阅读。一类内容缺失
| 范围选择 | `GET /v1/scopes`、`GET /v1/scopes/default`、`GET /v1/scopes/{scope_id}` | 默认值、显式选择、父子关系和可读范围分别处理 |
| 记忆目录与正文 | `POST /v1/atomic-memory/list`、`POST /v1/memory/search`、Atomic Memory 精确版本与状态读取 | 保留独立制品身份、生命周期筛选、搜索模式与命中依据;最多 50 条匹配结果 |
| 交接目录与正文 | `GET /v1/scopes/{scope_id}/artifacts/handoff` 及精确版本读取 | 保留游标;列表顺序不解释为时间顺序 |
| 经验目录与正文 | `GET /v1/scopes/{scope_id}/artifacts/experience`、`POST /v1/experience/get` | Dashboard 提供分页浏览;Family 相关性检索见 [Artifact 检索](../docs/workflows/search-artifacts.md) |
| 经验目录与正文 | `GET /v1/scopes/{scope_id}/artifacts/experience`、`POST /v1/experience/get`、`POST /v1/scopes/{scope_id}/artifacts/experience/search` | Dashboard 提供分页浏览与 Family 相关性搜索;搜索只覆盖 approved 且 active 的当前版本,最多返回 200 条,达到上限时提示缩小查询 |
| 技能目录与正文 | `POST /v1/skill/library`、`POST /v1/skill/get` | 库检索最多 200 项,达到上限时提示缩小查询;保留来源身份 |
| 原始材料 | `GET /v1/scopes/{scope_id}/sources/{source_type}/{source_id}` | 验证材料与记录的关联;当前可读取正文的来源类型为 `content` |
| 用量 | `POST /v1/stats` | 以 `exact` 读取当前范围,使用服务端周期与统计口径 |
Expand Down
10 changes: 6 additions & 4 deletions scripts/dashboard_browser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -782,14 +782,16 @@ async function main() {
await page.waitForURL(url => url.searchParams.get('kind') === 'skill');
const skills = (await api('/v1/skill/library', { scope_id: readingScope })).skills;
if (skills.length) {
await page.getByRole('searchbox').fill(skills[0].content.name);
const skillName = skills[0].content.name;
await page.getByRole('searchbox').fill(skillName);
await page.getByRole('button', { name: 'Search', exact: true }).click();
await page.waitForURL(url => url.searchParams.get('q') === skills[0].content.name);
await page.waitForURL(url => url.searchParams.get('q') === skillName);
assert(await page.getByRole('navigation', { name: 'Pagination', exact: true }).isVisible());
await page.getByRole('link', { name: skills[0].content.name, exact: true }).click();
await page.getByRole('link', { name: skillName, exact: true }).click();
await page.waitForURL(url => url.pathname.endsWith('/skill'));
await page.locator('.back-link').click();
await page.waitForURL(url => url.pathname.endsWith('/methods') && url.searchParams.get('kind') === 'skill');
await page.waitForURL(url =>
url.pathname.endsWith('/methods') && url.searchParams.get('kind') === 'skill' && url.searchParams.get('q') === skillName);
}
await page.goto(base + '/dashboard/home');
await context.setOffline(true);
Expand Down
32 changes: 30 additions & 2 deletions src/powercontext/server/dashboard/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
from powercontext.server.dashboard.errors import ReadError as ReadError
from powercontext.server.dashboard.session import authentication_headers

SEARCH_LIMIT = 200


def segment(value: str) -> str:
return quote(value, safe="")
Expand Down Expand Up @@ -248,6 +250,22 @@ def skill_record(value: dict[str, Any]) -> dict[str, Any]:
"artifacts": value["artifact_refs"],
}

@staticmethod
def search_record(item: dict[str, Any]) -> dict[str, Any]:
"""Adapt one family-scoped search hit to the Dashboard record shape."""

lineage = item["lineage"]
return {
**item["content"],
"artifact_id": item["artifact_id"],
"revision": item["revision"],
"sources": [
{"source_type": source["source_type"], "source_id": source["source_id"]}
for source in lineage["sources"]
],
"artifacts": lineage["artifacts"],
}

async def authorize_scope(self, scope: str, operation: str) -> None:
from powercontext.server.app import _authorize_checks

Expand All @@ -267,12 +285,22 @@ async def records(
self, scope: str, family: str, *, cursor: str | None = None, limit: int = 12, query: str | None = None
) -> dict[str, Any]:
if family == "skill":
maximum = 1 if limit == 1 else 200
maximum = 1 if limit == 1 else SEARCH_LIMIT
result = await self.read("/v1/skill/library", {"scope_id": scope, "limit": maximum, "query": query})
return {
"items": [self.skill_record(item) for item in result["skills"]],
"next_cursor": None,
"search_limited": maximum == 200 and len(result["skills"]) == maximum,
"search_limited": maximum == SEARCH_LIMIT and len(result["skills"]) == maximum,
}
if family == "experience" and query:
result = await self.read(
f"/v1/scopes/{segment(scope)}/artifacts/{segment(family)}/search",
{"query": query, "limit": SEARCH_LIMIT},
)
return {
"items": [self.search_record(item) for item in result["results"]],
"next_cursor": None,
"search_limited": len(result["results"]) == SEARCH_LIMIT,
}
if self.builtin is None:
url = httpx.URL(f"/v1/scopes/{segment(scope)}/artifacts/{family}", params={"limit": limit})
Expand Down
7 changes: 4 additions & 3 deletions src/powercontext/server/dashboard/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,17 @@ async def load_collection(api: DashboardAPI, request: Request, ctx: dict[str, An
ctx["collection_page_size"] = PAGE_SIZE
try:
cursor = request.query_params.get(f"{family}_cursor" if page == "methods" else "cursor")
query = ctx["search_query"] if page == "methods" else None
result = await api.records(
scope,
family,
cursor=cursor,
limit=1 if page == "home" else PAGE_SIZE,
query=ctx["search_query"] if family == "skill" and page == "methods" else None,
query=query,
)
if page != "home":
if family == "skill":
window = list_page(result["items"], request.query_params.get("skill_page"))
if family == "skill" or query:
window = list_page(result["items"], request.query_params.get(f"{family}_page"))
Comment thread
yydawx marked this conversation as resolved.
Resolved
result["items"] = window["items"]
result["pager"] = list_links(ctx, family, window)
else:
Expand Down
3 changes: 3 additions & 0 deletions src/powercontext/server/dashboard/labels.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,11 @@
"light": "Light",
"dark": "Dark",
"display_preferences": "Display Settings",
"search_experiences": "Search experiences",
"search_skills": "Search skills",
"search": "Search",
"experiences_limit": "Showing the first 200 experiences. Search to narrow the results.",
"experiences_no_match": "No matching experiences",
"skills_limit": "Showing the first 200 skills. Search to narrow the results.",
"skills_no_match": "No matching skills",
"pagination": "Pagination",
Expand Down
3 changes: 3 additions & 0 deletions src/powercontext/server/dashboard/labels.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,11 @@
"light": "浅色",
"dark": "深色",
"display_preferences": "显示设置",
"search_experiences": "搜索经验",
"search_skills": "搜索技能",
"search": "搜索",
"experiences_limit": "显示前 200 条经验,可通过搜索缩小结果范围。",
"experiences_no_match": "没有匹配的经验",
"skills_limit": "显示前 200 项技能,可通过搜索缩小结果范围。",
"skills_no_match": "没有匹配的技能",
"pagination": "分页",
Expand Down
54 changes: 43 additions & 11 deletions src/powercontext/server/dashboard/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
COMMON = {"scope", "period", "lang", "theme"}
HISTORY = {"view", "profile_cursor", "profile_history"}
COLLECTION = {"cursor", "handoff_history"}
METHODS = {"kind", "q", "experience_page", "skill_page"}
# A 2,000-character query can expand to 24 KiB when percent encoded.
METHODS_RETURN_MAX = 32 * 1024


def positive_revision(raw: str | None) -> int:
Expand All @@ -36,15 +39,19 @@ def _has_controls(value: str) -> bool:
return any(ord(char) < 32 or ord(char) == 127 for char in value)


def _parameters(raw: str, maximum: int) -> tuple[str, dict[str, str]]:
if len(raw.encode()) > maximum or _has_controls(raw) or "\\" in raw:
def _parameters(
raw: str, maximum: int, *, backslash_values: frozenset[str] = frozenset()
) -> tuple[str, dict[str, str]]:
if len(raw.encode()) > maximum or _has_controls(raw):
raise ValueError
url = urlsplit(raw)
if url.scheme or url.netloc or url.fragment or not url.path.startswith("/dashboard/"):
if url.scheme or url.netloc or url.fragment or not url.path.startswith("/dashboard/") or "\\" in url.path:
raise ValueError
pairs = parse_qsl(url.query, keep_blank_values=True, max_num_fields=12, errors="strict")
params = dict(pairs)
if len(params) != len(pairs) or any(_has_controls(value) or "\\" in value for value in params.values()):
if len(params) != len(pairs) or any(
_has_controls(value) or ("\\" in value and key not in backslash_values) for key, value in params.items()
):
raise ValueError
scope = params.get("scope", "")
if not scope.strip() or len(scope) > 256:
Expand Down Expand Up @@ -73,24 +80,49 @@ def _validate_history(params: dict[str, str]) -> None:
raise ValueError


def _validate_methods(params: dict[str, str]) -> None:
if params.get("kind", "experience") not in {"experience", "skill"}:
raise ValueError
for key in ("experience_page", "skill_page"):
if key in params and (not params[key].isascii() or not params[key].isdecimal() or int(params[key]) < 1):
raise ValueError


def collection_return(raw: str | None, scope: str, *, page: str | None = None) -> str | None:
if not raw:
return None
try:
path, params = _parameters(raw, 8192)
if page == "methods":
path, params = _parameters(raw, METHODS_RETURN_MAX, backslash_values=frozenset({"q"}))
else:
path, params = _parameters(raw, 8192)
if params["scope"] != scope or (page and path != f"/dashboard/{page}"):
return None
allowed = COMMON | COLLECTION if path == "/dashboard/handoff" else COMMON | HISTORY
if path not in {"/dashboard/handoff", "/dashboard/profile"} or params.keys() - allowed:
return None
if path == "/dashboard/profile" and params.get("view") != "history":
return None
_validate_history(params)
if page == "methods":
if params.keys() - (COMMON | METHODS):
return None
_validate_methods(params)
else:
allowed = COMMON | COLLECTION if path == "/dashboard/handoff" else COMMON | HISTORY
if path not in {"/dashboard/handoff", "/dashboard/profile"} or params.keys() - allowed:
return None
if path == "/dashboard/profile" and params.get("view") != "history":
return None
_validate_history(params)
except (ValueError, UnicodeError):
return None
return path + "?" + urlencode(params)


def with_language(destination: str, language: str) -> str:
"""Apply the current interface language to a validated return destination."""

url = urlsplit(destination)
params = dict(parse_qsl(url.query, keep_blank_values=True))
params["lang"] = language
return url.path + "?" + urlencode(params)


def _reading_fields(path: str, params: dict[str, str]) -> set[str]:
if path == "/dashboard/profile":
allowed = HISTORY
Expand Down
6 changes: 4 additions & 2 deletions src/powercontext/server/dashboard/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ def list_links(ctx: dict[str, Any], family: str, window: dict[str, Any]) -> dict
params: dict[str, Any] = {}
if family == "notes":
params.update(artifact=None, revision=None, notes_cursor=None, notes_history=None)
if family == "skill":
params["kind"] = "skill"
if family in {"skill", "experience"}:
params["kind"] = family
if family == "experience":
params.update(experience_cursor=None, experience_history=None)
return {
"page": window["page"],
**{
Expand Down
39 changes: 33 additions & 6 deletions src/powercontext/server/dashboard/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
directory_context,
positive_revision,
request_reading_return,
with_language,
)
from powercontext.server.dashboard.page import load_builtin_page
from powercontext.server.dashboard.pagination import PAGE_SIZE
Expand Down Expand Up @@ -130,6 +131,7 @@ def link(destination: str | None = None, fragment: str = "", **params: Any) -> s
"notes_history",
"note_state",
"skill_page",
"experience_page",
"experience_history",
"handoff_history",
"resource_type",
Expand Down Expand Up @@ -221,6 +223,7 @@ def reading_link_context(

def initial_context(request: Request, page: str) -> dict[str, Any]:
method_kind = request.query_params.get("kind", "experience")
return_page = "profile" if page == "profile" else "methods" if page in {"experience", "skill"} else "handoff"
ctx: dict[str, Any] = {
**presentation(request),
"page": page,
Expand Down Expand Up @@ -279,10 +282,12 @@ def initial_context(request: Request, page: str) -> dict[str, Any]:
"return_to": collection_return(
request.query_params.get("return_to"),
request.query_params.get("scope", ""),
page="profile" if page == "profile" else "handoff",
page=return_page,
),
"reading_return": request_reading_return(request),
}
if ctx["return_to"]:
ctx["return_to"] = with_language(ctx["return_to"], ctx["language"])
ctx["link"] = links(request, ctx)
return ctx

Expand Down Expand Up @@ -469,6 +474,17 @@ async def rollback_revision(request: Request) -> Response:
family = str(form.get("family") or "")
artifact_id = str(form.get("artifact_id") or "")
reason = str(form.get("reason") or "")
return_page = {
"experience": "methods",
"skill": "methods",
"handoff": "handoff",
"profile": "profile",
}.get(family)
return_to = (
collection_return(str(form.get("return_to") or "") or None, scope, page=return_page)
if return_page is not None
else None
)
try:
source_revision = positive_revision(str(form.get("source_revision") or ""))
expected_revision = positive_revision(str(form.get("expected_revision") or ""))
Expand All @@ -487,7 +503,9 @@ async def rollback_revision(request: Request) -> Response:
)
except ReadError as error:
code = "forbidden" if error.status == 403 else "rejected"
return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303)
return RedirectResponse(
_rollback_return(scope, family, artifact_id, source_revision, code, return_to), status_code=303
)
finally:
await api.client.aclose()
if response.status_code == 412:
Expand All @@ -498,12 +516,21 @@ async def rollback_revision(request: Request) -> Response:
code = "rejected"
else:
created = response.json()["revision"]
return RedirectResponse(_rollback_return(scope, family, artifact_id, created, None), status_code=303)
return RedirectResponse(_rollback_return(scope, family, artifact_id, source_revision, code), status_code=303)
return RedirectResponse(_rollback_return(scope, family, artifact_id, created, None, return_to), status_code=303)
return RedirectResponse(
_rollback_return(scope, family, artifact_id, source_revision, code, return_to), status_code=303
)


def _rollback_return(scope: str, family: str, artifact_id: str, revision: int, error: str | None) -> str:
params: dict[str, Any] = {"scope": scope, "rollback_error": error}
def _rollback_return(
scope: str,
family: str,
artifact_id: str,
revision: int,
error: str | None,
return_to: str | None = None,
) -> str:
params: dict[str, Any] = {"scope": scope, "rollback_error": error, "return_to": return_to}
if family == "profile":
page = "profile"
params["revision"] = revision
Expand Down
Loading
Loading