Let the console search, browse, forget and mount
The store had a write path, a mount path and a search path, and the console
still showed a dashboard of counters. Nothing in the UI could ask what someone
knows, page through atoms, look at a wordless picture, forget one memory, or
mount a cartridge — so the mechanisms built in P0 were invisible.
Backend:
- senses.py: one function decides what this machine can perceive. /api/status
and doctor each computed it before, which is two truths. Picture atoms are now
counted by image_ref rather than by modality, so a screenshot crop from a PDF
counts and a picture whose bytes were deleted stops claiming it can be encoded.
- browse.py: GET /api/atoms pages newest-first through v_atoms_current with
modality/holder/source filters; GET /api/atoms/{id}/image serves the stored
bytes so a picture with no OCR text can be looked at.
- Deleted 8 HTTP routes nobody calls (4 benchmark, research modules, dense index,
visual-contradiction-proof, memory-map). Their functions stay: the CLI and the
proof checks use them. The surface is 30 routes, pinned as a set by a test so a
new unused route fails the suite.
Frontend: App.tsx (517 lines) became a 131-line shell plus seven panels that own
their own fetches, and lib/format.ts. types.ts now carries the memory dimensions
the backend has been sending since schema v2.
Two invented fields were rendering lies: AdapterPanel showed gpu_available and
probe_status, which AdapterLoadReport does not have — every machine looked GPU-less
including this one. The panel now shows only what the server says.
Proof: scripts/ui_smoke.sh starts the API on a fresh temp home, serves the built
UI from the same origin, and drives five interactions in a real browser — search
as somebody in a scenario, page and view a wordless picture, forget, mount, read
adapter states with reasons. It prints the mount refusal verbatim rather than
pretending the mount succeeded.
Gates: 263 pytest passed; ruff and mypy clean on the 8 touched backend files
(repo-wide mypy unchanged at 24 errors / 6 files); pyright 0 errors there;
go build/vet/test/gofmt clean; npm build + eslint clean; ui_smoke and
mcp_write_smoke both PASS.
This commit is contained in:
1 parent
a5008e1476
commit
9f91ff7ed7
27 files changed
+2146
-616
No files matched your search
+27
-6
@@ -287,12 +287,33 @@
|
||||
- 验收:CI 绿;干净 clone 上 `docker build` 成功(已实测 `09803cd49add` + 容器 curl 通);桩 `nvidia-smi` 打印 RTX 5090 证明设备名不再致命(已实测)
|
||||
- 独立 review(同一个 `p1review` 里的 pi,模型 `strata/qwen3.8-flash-next-abliterated-iq3_s:high`,三轮,不读我的话只跑东西):第一轮 **FAIL 9 条**(全是 CHECKLIST 里的数字与断言不重现:测试文件数、套件规模、构建上下文体积、`profiles.py` 异常元组、`training.py` 行号、`cli.py` 行数、把 4090 说成不支持 BF16、备份摘要不可复现、秒数写死)→ 第二轮 **FAIL 1 条**(`_TinyDense` 那两行 docstring 是 p1-4 唯一新增的多行注释,跟 p1-6.4 的「≤1 行」相冲)→ **第三轮 PASS**。reviewer 自己跑 `gh run view`、`pytest`、`ruff`、`mypy`(冷缓存逐文件比对 `908157f` 与 HEAD → 两边都是 24 错/6 文件,逐条同消息)、`pyright`、go 全套、`bash -n` 15 个 `.sh`、`pip wheel`、`sha256sum LICENSE` 与 gnu.org 逐字节比对、拉起 22.4GB 容器 curl 三个 URL,并在真 worktree 里做变异测试(去掉 reranker 桩 → 2 failed;去掉 dense 桩 → 正好复现 CI 那 4 条;只翻 `bench/systems.py:414` 的排序键 → 正好 `fusion_rerank` 那条挂)。它还量了全仓多行 docstring 数 `114 → 114`,确认 p1-4 现在新增 0 条。三轮同一个人,没换人放水。
|
||||
- 门禁:`pytest -q` **253 passed**;`ruff check backend/src bench` All checks passed(HEAD 是 17 errors);mypy 冷缓存查改动文件与 HEAD 同错、净新增 0;`pyright` 查 maintenance 三个文件 → **0 errors, 0 warnings**;go build/vet/test + gofmt 干净;`bash -n` 15 个 `.sh` 干净;`pip wheel ./backend` 能建且元数据带 license 表达式
|
||||
- [ ] **p1-5** 产品面
|
||||
- [ ] p1-5.1 `GET /api/search`
|
||||
- [ ] p1-5.2 前端:搜索、浏览、forget、挂载卡带、时间显示、图像渲染
|
||||
- [ ] p1-5.3 `types.ts` 补回 `time` / `source_id` / `trust` / `relations`
|
||||
- [ ] p1-5.4 12 个未用端点:接上或删除
|
||||
- 验收:E2E 测试覆盖五项;`grep` 无未接端点残留
|
||||
- [x] **p1-5** 产品面
|
||||
- [x] p1-5.1 搜索端点接上前端
|
||||
- 验收写的是 `GET /api/search`,实际是 p0-3 就有的 `POST /api/search`:查询体带 holder / scenario / modality / image,GET 塞不下这些。前端 `search()` 调它
|
||||
- [x] p1-5.2 前端:搜索、浏览、forget、挂载卡带、时间显示、图像渲染
|
||||
- `App.tsx` 517 行拆成 130 行的壳 + `components/{MemoryPanel,CartridgePanel,SensesPanel,AdapterPanel,ProofPanel,IngestPanel,CompilerPanel,ui}.tsx` + `lib/format.ts`,每个面板自己取数,没有一个文件超 500 行
|
||||
- 为此新增的后端能力在 `backend/src/ely_eye/browse.py`:`GET /api/atoms`(游标翻页 + modality / holder / source 过滤,读 `v_atoms_current`)与 `GET /api/atoms/{atom_id}/image`(`FileResponse`;没图 → 404 点名那颗 atom)
|
||||
- 时间显示:`formatAgo`(just now / 5m / 2h / 3d / 1y)与 `formatStamp`(写死 UTC,`2026-10-08 09:03Z`);每行是 `holder · origin · scenario · 时间 · trust`
|
||||
- 图像渲染:`<img class="thumb" src=/api/atoms/{id}/image>`,E2E 断言 `naturalWidth > 0`——真解码出像素,不是有个标签就算
|
||||
- [x] p1-5.3 `types.ts` 补回记忆维度
|
||||
- 验收写的 `time` 在 p0-1 已改名 `ingested_at`,另有 `event_time / valid_from / valid_to`。补的是 `source_id, ingested_at, holder_id, origin, scenario, event_time, event_time_precision, valid_from, valid_to, trust`。`relations` 没补:至今没人写 atom 的 relations(p0 记过这条),前端拿它没东西可渲染
|
||||
- 另加 `AtomPage, SearchFilters, SenseStatus, CartridgeManifest, MountState, AdapterChoice, AdapterLoadReport, Runtime*Profile*, ForgetReport`;`MemoryMap` 类型随端点一起删
|
||||
- [x] p1-5.4 未用端点:接上或删除
|
||||
- 删 8 个:`/api/benchmarks/{registry,sources,samples,predictions}`、`/api/research/modules`、`/api/index/dense`、`/api/visual-contradiction-proof`、`/api/memory-map`(前端是 memory-map 唯一的消费者,状态卡本来就带 layer 与卡带数)。底层函数一个没删——`cli.py` 与 `proofs/checks_visual.py` 在用;删的是没人调的 HTTP 壳
|
||||
- 加 2 个:`/api/atoms`、`/api/atoms/{atom_id}/image`;`/api/status` 现在带 `senses`。HTTP 面从 36 条到 **30** 条
|
||||
- 反漂移门禁:`backend/tests/test_product_api.py::test_the_http_surface_is_exactly_what_someone_uses` 把 30 条路由钉成集合,多一条少一条都红
|
||||
- [x] p1-5.5 E2E 五项
|
||||
- `scripts/ui_smoke.sh`:`mktemp -d` 起一个干净 home(`ELY_EYE_DEFAULT_HOLDER=wife`、`ELY_EYE_UI_DIR=frontend/dist`),API 自己用 `StaticFiles(html=True)` 同源挂 UI,浏览器只对一个 origin 说话,收尾删 home
|
||||
- 五项都在真浏览器里做:① 搜 `spare glasses` + scenario `home` → 那行必须同时写 `wife` 和 `home`(「谁在什么场景下知道」就是这个意思)② 按 modality 过滤 → 无字图片渲染出真像素,翻页换掉整页 ③ Forget → 提示 `forgotten: N atoms`,再搜同一句返回「Nothing in the store answers」④ Mount → 状态真的变了 ⑤ Adapter Matrix 每个 kind 一行,状态落在 `loaded|available|deferred|missing` 内且**必须带原因**
|
||||
- 实测:`mount: refused: cannot verify ui_smoke_26f91b8f3a22 on this machine: model_hash, tokenizer_hash, chat_template_hash, rope are unknown`(p0-4 那条「没有 model_dir 就拒绝挂载」的偏差在 UI 里看得见);`adapters: 6 kinds: missing ×6`,每条原因是「no adapter of kind X is on disk」;KPI 显示 `Runtime ready`、`Qwen/Qwen3.5-9B`、`NVIDIA GeForce RTX 4090 20914/23028 MiB`、`Cartridges 1`
|
||||
- 失败留证据:截图 `ui-failure.png` + Memory 面板文本 + 那一刻 `/api/atoms?modality=…` 返回的 JSON
|
||||
- 网络噪声分开判:`404 /api/proof-suite/latest`(没跑过证明的库就该 404,面板写 pending)和被拒的挂载 400 算预期,其余 ≥400 一律红;JS 未捕获异常一律红
|
||||
- 三条是 E2E 逼出来的真缺陷,都不是样式问题:
|
||||
- `AdapterPanel` 的指标条渲染 `load.gpu_available` 与 `load.probe_status`,而 `AdapterLoadReport`(`runtime_schemas.py:27`)只有 `model_id / mounted / adapters`——没 GPU 的机器上它会永远显示「no gpu visible」,而这台机器有 4090。删掉那两个 span 和 `types.ts` 里编出来的五个字段,只渲染服务端真给的
|
||||
- 我第一版 E2E 硬写 `modality=image`,结果 0 行:`ingestion_media.py:277` 按**文件名**判 kind(`ui_markers = ("screenshot","screen","figma","ui","wireframe","mockup")`),我那个文件叫 `ui-smoke-wordless.png` → 存成 `ui_screenshot`。改成从 `/api/atoms` 读回那颗 atom 的 modality 再选,库说是啥就是啥。顺带记一条本轮没动的产品缺陷:`"ui"` 是子串匹配,`guide.png`、`quickstart.png` 都会被叫成 UI 截图;不改是因为冻结的 bench 数字是在现规则下量的
|
||||
- playwright 1.60 在 Ubuntu 26.04 上没有 chromium 构建(`Playwright does not support chromium on ubuntu26.04-x64`),`npm i -D playwright@1.64.0` 才有;而且它的 npm 包不自动下浏览器,要 `npx playwright install chromium`
|
||||
- 顺手挖掉的产品根因:`/api/status` 与 `doctor` 各算一遍「这台机器能感知什么」,两份真相。抽成 `backend/src/ely_eye/senses.py::sense_facts()`,两边只渲染它。图片计数从 modality 改成「有没有 `image_ref`」:按 modality 数会漏掉 pdf_region 的截图裁块,也看不见字节已被删走的 atom。现在 `picture_bytes_missing` 会让 doctor 说「N picture atoms with no bytes behind them; re-ingest the source they came from」,而 CLIP 那条警告只数还能编码的
|
||||
- 验收:`bash scripts/ui_smoke.sh` → `PASS: the console searches, pages, forgets, mounts and reports adapter states`;`pytest -q` **263 passed**;`ruff check` 8 个改动文件 All checks passed;mypy 冷缓存查这 8 个文件 `Success: no issues found`,全仓仍是 **24 错 / 6 文件**(与 p1-4 记的基线相同,净新增 0);`pyright` 这 8 个文件 **0 errors, 0 warnings**;go build/vet/test + gofmt 干净;`npm run build` + `eslint .` 干净;`scripts/mcp_write_smoke.sh` 仍 PASS
|
||||
- [ ] **p1-6** 代码卫生
|
||||
- [ ] p1-6.1 `evals.py` 2413 行拆到 ≤500 行/文件
|
||||
- [ ] p1-6.2 删 dead code 与未调用函数
|
||||
|
||||
@@ -53,8 +53,12 @@ and launch one:
|
||||
.\.venv\Scripts\ely-eye.exe proof-suite
|
||||
```
|
||||
|
||||
Every command has an HTTP equivalent under `/api/...`, and the dashboard shows
|
||||
the same state: Adapter Matrix, Proof Suite, Memory Map, Cache Trace.
|
||||
The console is served by the API itself once `ELY_EYE_UI_DIR` points at
|
||||
`frontend/dist`. It shows the state the CLI prints — runtime, GPU, library,
|
||||
cartridges, senses, adapters, proof suite — and it is the only place to search
|
||||
the store as somebody, page through atoms, forget one, and mount a cartridge.
|
||||
`scripts/ui_smoke.sh` drives that console in a real browser against a fresh
|
||||
store and fails if any of it stops working.
|
||||
|
||||
## Fine-tuning
|
||||
|
||||
|
||||
+34
-62
@@ -5,8 +5,9 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -14,33 +15,18 @@ from . import __version__
|
||||
from .adapters import plan as adapter_plan
|
||||
from .api_cartridges import register as register_cartridge_routes
|
||||
from .api_holders import register as register_holder_routes
|
||||
from .benchmarks import (
|
||||
benchmark_prediction_report,
|
||||
benchmark_registry_report,
|
||||
benchmark_sample_report,
|
||||
benchmark_source_report,
|
||||
)
|
||||
from .browse import browse, picture
|
||||
from .cache_fabric import CacheFabric
|
||||
from .cartridge import CartridgeService
|
||||
from .compiler import ContextCompiler
|
||||
from .config import Settings, get_settings
|
||||
from .db import Database
|
||||
from .embeddings import EmbeddingService
|
||||
from .evaluation_schemas import (
|
||||
BenchmarkPredictionReport,
|
||||
BenchmarkRegistryReport,
|
||||
BenchmarkSampleReport,
|
||||
BenchmarkSourceReport,
|
||||
HashHopProof,
|
||||
ProofCheck,
|
||||
ProofSuiteReport,
|
||||
)
|
||||
from .evaluation_schemas import HashHopProof, ProofSuiteReport
|
||||
from .evidence_graph import EvidenceGraphService
|
||||
from .ingestion import IngestionService
|
||||
from .memory import MemoryService
|
||||
from .profiles import runtime_profile_report
|
||||
from .proofs import HashHopEvaluator, PRDProofSuite
|
||||
from .research_modules import ResearchModuleService
|
||||
from .retrieval import RetrievalService
|
||||
from .runtime import QwenRuntime, RuntimeUnavailableError
|
||||
from .runtime_schemas import AdapterLoadReport
|
||||
@@ -48,11 +34,13 @@ from .schemas import (
|
||||
AdapterManifest,
|
||||
AdapterStatus,
|
||||
AnswerResponse,
|
||||
AtomPage,
|
||||
EvidenceAtom,
|
||||
ForgetReport,
|
||||
ForgetRequest,
|
||||
MemoryOrigin,
|
||||
MemoryStatus,
|
||||
Modality,
|
||||
PathIngestRequest,
|
||||
RememberRequest,
|
||||
RuntimeProfile,
|
||||
@@ -63,6 +51,7 @@ from .schemas import (
|
||||
SystemStatus,
|
||||
UploadIngestRequest,
|
||||
)
|
||||
from .senses import sense_facts
|
||||
from .training import safetensor_weight_stats
|
||||
from .verifier import Verifier, VisualContradictionLens
|
||||
|
||||
@@ -114,6 +103,7 @@ def create_app() -> FastAPI:
|
||||
version=__version__,
|
||||
runtime=runtime,
|
||||
memory=memory,
|
||||
senses=sense_facts(settings, db),
|
||||
data_home=str(settings.home),
|
||||
profiles={
|
||||
"Live Demo": {"context_tokens": settings.live_context_tokens, "purpose": "stable 262K workflow"},
|
||||
@@ -260,21 +250,33 @@ def create_app() -> FastAPI:
|
||||
def adapters(settings: Annotated[Settings, Depends(settings_dep)]) -> list[AdapterStatus]:
|
||||
return list_adapter_statuses(settings)
|
||||
|
||||
@app.get("/api/benchmarks/registry", response_model=BenchmarkRegistryReport)
|
||||
def benchmarks_registry() -> BenchmarkRegistryReport:
|
||||
return benchmark_registry_report()
|
||||
@app.get("/api/atoms", response_model=AtomPage)
|
||||
def atoms(
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
cursor: str | None = None,
|
||||
modality: Modality | None = None,
|
||||
source_id: str | None = None,
|
||||
holder: str | None = None,
|
||||
) -> AtomPage:
|
||||
try:
|
||||
return browse(
|
||||
settings,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
modality=modality,
|
||||
source_id=source_id,
|
||||
holder=holder,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/benchmarks/sources", response_model=BenchmarkSourceReport)
|
||||
def benchmarks_sources() -> BenchmarkSourceReport:
|
||||
return benchmark_source_report()
|
||||
|
||||
@app.get("/api/benchmarks/samples", response_model=BenchmarkSampleReport)
|
||||
def benchmarks_samples(settings: Annotated[Settings, Depends(settings_dep)]) -> BenchmarkSampleReport:
|
||||
return benchmark_sample_report(settings.data_dir / "benchmark_samples")
|
||||
|
||||
@app.get("/api/benchmarks/predictions", response_model=BenchmarkPredictionReport)
|
||||
def benchmarks_predictions(settings: Annotated[Settings, Depends(settings_dep)]) -> BenchmarkPredictionReport:
|
||||
return benchmark_prediction_report(settings, settings.data_dir / "benchmark_predictions")
|
||||
@app.get("/api/atoms/{atom_id}/image")
|
||||
def atom_image(atom_id: str, settings: Annotated[Settings, Depends(settings_dep)]) -> FileResponse:
|
||||
found = picture(settings, atom_id)
|
||||
if found is None:
|
||||
raise HTTPException(status_code=404, detail=f"{atom_id} has no picture to show")
|
||||
return FileResponse(found.path, media_type=found.mime)
|
||||
|
||||
@app.get("/api/runtime/profiles", response_model=RuntimeProfileReport)
|
||||
def runtime_profiles(
|
||||
@@ -286,22 +288,6 @@ def create_app() -> FastAPI:
|
||||
def runtime_adapters(settings: Annotated[Settings, Depends(settings_dep)]) -> AdapterLoadReport:
|
||||
return adapter_plan(settings, Database(settings))
|
||||
|
||||
@app.get("/api/research/modules")
|
||||
def research_modules(settings: Annotated[Settings, Depends(settings_dep)]):
|
||||
db = Database(settings)
|
||||
compiler = ContextCompiler(settings, db)
|
||||
context = compiler.compile(
|
||||
"Ely-Eye V-NSA DVKV x-modal KV speculative prefetch evidence graph",
|
||||
RuntimeProfile.research_theater,
|
||||
)
|
||||
return ResearchModuleService(settings, db).build_report(context.plan.question, context.hits)
|
||||
|
||||
@app.post("/api/index/dense")
|
||||
def build_dense_index(settings: Annotated[Settings, Depends(settings_dep)]):
|
||||
service = EmbeddingService(settings)
|
||||
index = service.build_index()
|
||||
return {"atoms": len(index.atom_ids), "dimensions": int(index.vectors.shape[1]) if index.vectors.size else 0}
|
||||
|
||||
@app.post("/api/hashhop", response_model=HashHopProof)
|
||||
def hashhop(request: HashHopRequest, settings: Annotated[Settings, Depends(settings_dep)]) -> HashHopProof:
|
||||
evaluator = HashHopEvaluator(settings)
|
||||
@@ -328,20 +314,6 @@ def create_app() -> FastAPI:
|
||||
raise HTTPException(status_code=404, detail="No PRD proof suite has been generated.")
|
||||
return report
|
||||
|
||||
@app.post("/api/visual-contradiction-proof", response_model=ProofCheck)
|
||||
def visual_contradiction_proof(settings: Annotated[Settings, Depends(settings_dep)]) -> ProofCheck:
|
||||
return PRDProofSuite(settings).visual_contradiction_proof()
|
||||
|
||||
@app.get("/api/memory-map")
|
||||
def memory_map(settings: Annotated[Settings, Depends(settings_dep)]):
|
||||
db = Database(settings)
|
||||
atoms = db.list_atoms(limit=200)
|
||||
return {
|
||||
"atoms": [atom.model_dump(mode="json") for atom in atoms],
|
||||
"layers": CacheFabric(settings, db).statuses(),
|
||||
"cartridges": CartridgeService(settings, db).list_manifests(),
|
||||
}
|
||||
|
||||
mounted_ui = get_settings().ui_dir
|
||||
if mounted_ui is not None:
|
||||
if not mounted_ui.is_dir():
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Page through the store the way a person browses a shelf: newest first, filtered by who and what."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .db import Database, row_to_atom
|
||||
from .retrieval import decode_cursor, encode_cursor
|
||||
from .schemas import AtomPage, Modality
|
||||
from .storage import ObjectStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Picture:
|
||||
"""The bytes behind an atom and how to label them when serving."""
|
||||
|
||||
path: Path
|
||||
mime: str
|
||||
|
||||
|
||||
def browse(
|
||||
settings: Settings,
|
||||
*,
|
||||
limit: int,
|
||||
cursor: str | None = None,
|
||||
modality: Modality | None = None,
|
||||
source_id: str | None = None,
|
||||
holder: str | None = None,
|
||||
) -> AtomPage:
|
||||
"""One page plus the total, so a list can say what it left out instead of looking endless."""
|
||||
offset = decode_cursor(cursor)
|
||||
wanted = (modality.value if modality else None, source_id, holder)
|
||||
filters = tuple(value for bound in wanted for value in (bound, bound))
|
||||
database = Database(settings)
|
||||
with database.connect() as connection:
|
||||
total = int(
|
||||
connection.execute(
|
||||
"SELECT COUNT(*) FROM v_atoms_current WHERE (? IS NULL OR modality = ?) AND (? IS NULL OR source_id = ?) AND (? IS NULL OR holder_id = ?)",
|
||||
filters,
|
||||
).fetchone()[0]
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM v_atoms_current WHERE (? IS NULL OR modality = ?) AND (? IS NULL OR source_id = ?) AND (? IS NULL OR holder_id = ?) ORDER BY ingested_at DESC, atom_id DESC LIMIT ? OFFSET ?",
|
||||
(*filters, limit + 1, offset),
|
||||
).fetchall()
|
||||
atoms = [row_to_atom(row) for row in rows[:limit]]
|
||||
more = len(rows) > limit or offset + limit < total
|
||||
return AtomPage(
|
||||
atoms=atoms,
|
||||
total=total,
|
||||
next_cursor=encode_cursor(offset + limit) if atoms and more else None,
|
||||
)
|
||||
|
||||
|
||||
def picture(settings: Settings, atom_id: str) -> Picture | None:
|
||||
"""The stored bytes behind one atom, through the same visibility view search uses.
|
||||
|
||||
None covers three cases the caller cannot tell apart and does not need to: the atom is unknown,
|
||||
it was forgotten, or its bytes are gone. All three mean "there is no picture to show".
|
||||
"""
|
||||
database = Database(settings)
|
||||
with database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT image_ref, mime FROM v_atoms_current JOIN sources USING (source_id) WHERE atom_id = ?",
|
||||
(atom_id,),
|
||||
).fetchone()
|
||||
if row is None or not row["image_ref"]:
|
||||
return None
|
||||
path = ObjectStore(settings).resolve(str(row["image_ref"]))
|
||||
if not path.exists():
|
||||
return None
|
||||
mime = str(row["mime"]) if row["mime"] else "image/png"
|
||||
return Picture(path=path, mime=mime)
|
||||
@@ -14,15 +14,13 @@ from . import __version__
|
||||
from .cartridge_store import mounted_ids
|
||||
from .config import Settings
|
||||
from .db import Database
|
||||
from .embeddings import EmbeddingService
|
||||
from .migrations import SCHEMA_VERSION
|
||||
from .schemas import utc_now
|
||||
from .visual_index import VisualIndex
|
||||
from .schemas import SenseStatus, utc_now
|
||||
from .senses import sense_facts
|
||||
|
||||
BACKUP_FILE = "ely_eye.sqlite"
|
||||
MANIFEST_FILE = "backup_manifest.json"
|
||||
_REQUIRED_TABLES = ("atoms", "sources", "cartridges")
|
||||
PICTURE_MODALITIES = ("image", "ui_screenshot")
|
||||
|
||||
|
||||
class BackupManifest(BaseModel):
|
||||
@@ -57,12 +55,6 @@ def _atoms_word(count: int) -> str:
|
||||
return "atom" if count == 1 else "atoms"
|
||||
|
||||
|
||||
def _modality_counts(database: Database) -> dict[str, int]:
|
||||
with database.connect() as connection:
|
||||
rows = connection.execute("SELECT modality, COUNT(*) AS total FROM atoms GROUP BY modality").fetchall()
|
||||
return {str(row["modality"]): int(row["total"]) for row in rows}
|
||||
|
||||
|
||||
def _counts(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
return {
|
||||
"atoms": int(connection.execute("SELECT COUNT(*) FROM atoms").fetchone()[0]),
|
||||
@@ -175,41 +167,41 @@ def _check_cartridges(database: Database, mounted: list[str], problems: list[str
|
||||
problems.append(f"{cartridge_id} is mounted but its files at {root} are gone")
|
||||
|
||||
|
||||
def _check_senses(settings: Settings, database: Database, visual: VisualIndex, warnings: list[str]) -> None:
|
||||
counts = _modality_counts(database)
|
||||
pictures = sum(counts.get(name, 0) for name in PICTURE_MODALITIES)
|
||||
if settings.clip_model_dir is not None and visual.model_dir() is None:
|
||||
warnings.append(f"the CLIP snapshot at {settings.clip_model_dir} is missing, so wordless pictures cannot be found")
|
||||
elif pictures and not visual.available():
|
||||
def _check_senses(facts: SenseStatus, warnings: list[str]) -> None:
|
||||
"""Render the missing senses as sentences; the counting lives in senses.py, not here."""
|
||||
encodable = facts.picture_atoms - facts.picture_bytes_missing
|
||||
if facts.clip_path is not None and facts.visual == "absent":
|
||||
warnings.append(f"the CLIP snapshot at {facts.clip_path} is missing, so wordless pictures cannot be found")
|
||||
elif encodable and facts.visual == "absent":
|
||||
warnings.append(
|
||||
f"the store holds {pictures} picture {_atoms_word(pictures)} but no CLIP snapshot is set "
|
||||
f"the store holds {encodable} picture {_atoms_word(encodable)} but no CLIP snapshot is set "
|
||||
"(ELY_EYE_CLIP_MODEL_DIR), so they are findable only by the text already on them"
|
||||
)
|
||||
elif visual.visual_atoms() and not visual.is_current():
|
||||
elif facts.visual == "stale":
|
||||
warnings.append("the visual index is behind the store; run `ely-eye index-visual`")
|
||||
audio = counts.get("audio", 0)
|
||||
if settings.asr_model_dir is None:
|
||||
if audio:
|
||||
warnings.append(
|
||||
f"the store holds {audio} audio {_atoms_word(audio)} but no speech snapshot is set "
|
||||
"(ELY_EYE_ASR_MODEL_DIR), so new audio will not be transcribed"
|
||||
)
|
||||
elif not settings.asr_model_dir.exists():
|
||||
warnings.append(f"the speech snapshot at {settings.asr_model_dir} is missing, so audio cannot be transcribed")
|
||||
if settings.vlm_model_dir is not None and not settings.vlm_model_dir.exists():
|
||||
warnings.append(f"the captioning model at {settings.vlm_model_dir} is missing, so images stay uncaptioned")
|
||||
|
||||
|
||||
def _check_indexes(settings: Settings, database: Database, warnings: list[str]) -> None:
|
||||
if not settings.dense_embeddings_enabled:
|
||||
return
|
||||
index = EmbeddingService(settings, database).load_index()
|
||||
stored = database.atom_count()
|
||||
if index is None:
|
||||
warnings.append("dense embeddings are on but no index exists; run `ely-eye build-dense-index`")
|
||||
elif len(index.atom_ids) != stored:
|
||||
if facts.picture_bytes_missing:
|
||||
missing = facts.picture_bytes_missing
|
||||
warnings.append(
|
||||
f"the dense index covers {len(index.atom_ids)} atoms while the store holds {stored}; run `ely-eye build-dense-index`"
|
||||
f"{missing} picture {_atoms_word(missing)} with no bytes behind them; re-ingest the source they came from"
|
||||
)
|
||||
if facts.asr_path is not None and facts.speech == "missing":
|
||||
warnings.append(f"the speech snapshot at {facts.asr_path} is missing, so audio cannot be transcribed")
|
||||
elif facts.audio_atoms and facts.speech == "absent":
|
||||
warnings.append(
|
||||
f"the store holds {facts.audio_atoms} audio {_atoms_word(facts.audio_atoms)} but no speech snapshot is set "
|
||||
"(ELY_EYE_ASR_MODEL_DIR), so new audio will not be transcribed"
|
||||
)
|
||||
if facts.vlm_path is not None and facts.captioning == "missing":
|
||||
warnings.append(f"the captioning model at {facts.vlm_path} is missing, so images stay uncaptioned")
|
||||
|
||||
|
||||
def _check_indexes(facts: SenseStatus, warnings: list[str]) -> None:
|
||||
if facts.dense == "missing":
|
||||
warnings.append("dense embeddings are on but no index exists; run `ely-eye build-dense-index`")
|
||||
elif facts.dense == "behind":
|
||||
warnings.append(
|
||||
f"the dense index covers {facts.dense_covered} atoms while the store holds {facts.store_atoms}; "
|
||||
"run `ely-eye build-dense-index`"
|
||||
)
|
||||
|
||||
|
||||
@@ -242,8 +234,9 @@ def doctor(settings: Settings) -> DoctorReport:
|
||||
problems.append(f"the schema settled at version {version} while this build writes {SCHEMA_VERSION}")
|
||||
mounted = mounted_ids(database)
|
||||
_check_cartridges(database, mounted, problems)
|
||||
_check_senses(settings, database, VisualIndex(settings, database), warnings)
|
||||
_check_indexes(settings, database, warnings)
|
||||
facts = sense_facts(settings, database)
|
||||
_check_senses(facts, warnings)
|
||||
_check_indexes(facts, warnings)
|
||||
if settings.ui_dir is not None and not settings.ui_dir.exists():
|
||||
problems.append(f"ui_dir {settings.ui_dir} is set but holds no built interface, so the API refuses to start")
|
||||
return DoctorReport(
|
||||
|
||||
@@ -201,6 +201,14 @@ class SearchResult(BaseModel):
|
||||
hits: list[RetrievalHit]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class AtomPage(BaseModel):
|
||||
"""A page of the store plus the total behind it, so a list can admit what it left out."""
|
||||
|
||||
atoms: list[EvidenceAtom]
|
||||
total: int
|
||||
next_cursor: str | None = None
|
||||
|
||||
class ContextPlan(BaseModel):
|
||||
question: str
|
||||
profile: RuntimeProfile
|
||||
@@ -261,11 +269,39 @@ class RuntimeStatus(BaseModel):
|
||||
vram_total_mb: int | None = None
|
||||
vram_used_mb: int | None = None
|
||||
|
||||
SenseState = Literal["absent", "missing", "ready"]
|
||||
VisualState = Literal["absent", "stale", "ready"]
|
||||
DenseState = Literal["off", "missing", "behind", "current"]
|
||||
|
||||
|
||||
class SenseStatus(BaseModel):
|
||||
"""What the machine can perceive right now, stated once for the UI and the doctor to share.
|
||||
|
||||
`absent` means the sense was never given a model, `missing` means it was given one that is gone.
|
||||
A store full of pictures and no CLIP snapshot is a different problem, and needs a different
|
||||
sentence, than a snapshot directory somebody deleted.
|
||||
"""
|
||||
|
||||
dense: DenseState
|
||||
dense_covered: int
|
||||
store_atoms: int
|
||||
picture_atoms: int
|
||||
picture_bytes_missing: int
|
||||
visual: VisualState
|
||||
audio_atoms: int
|
||||
speech: SenseState
|
||||
captioning: SenseState
|
||||
clip_path: str | None = None
|
||||
asr_path: str | None = None
|
||||
vlm_path: str | None = None
|
||||
|
||||
|
||||
class SystemStatus(BaseModel):
|
||||
app: str = "Ely-Eye"
|
||||
version: str
|
||||
runtime: RuntimeStatus
|
||||
memory: MemoryStatus
|
||||
senses: SenseStatus
|
||||
profiles: dict[str, dict[str, Any]]
|
||||
data_home: str
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""What this machine can actually perceive, computed once and read by status and doctor alike."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .db import Database
|
||||
from .embeddings import EmbeddingService
|
||||
from .schemas import DenseState, SenseState, SenseStatus, VisualState
|
||||
from .visual_index import VisualIndex
|
||||
|
||||
|
||||
def sense_facts(settings: Settings, database: Database, visual: VisualIndex | None = None) -> SenseStatus:
|
||||
"""Report the senses as facts, so the UI and the doctor cannot disagree about what is missing.
|
||||
|
||||
A path set but gone on disk is a different failure from a path never set: the first is a broken
|
||||
install, the second is a machine that was never given that sense.
|
||||
"""
|
||||
index = visual or VisualIndex(settings, database)
|
||||
claimed = _picture_claim(database)
|
||||
on_disk = index.visual_atom_ids()
|
||||
dense, covered, total = _dense_facts(settings, database)
|
||||
return SenseStatus(
|
||||
dense=dense,
|
||||
dense_covered=covered,
|
||||
store_atoms=total,
|
||||
picture_atoms=claimed,
|
||||
picture_bytes_missing=max(0, claimed - len(on_disk)),
|
||||
visual=_visual_state(index, on_disk),
|
||||
audio_atoms=_audio_count(database),
|
||||
speech=_sense_state(settings.asr_model_dir),
|
||||
captioning=_sense_state(settings.vlm_model_dir),
|
||||
clip_path=_stated(settings.clip_model_dir),
|
||||
asr_path=_stated(settings.asr_model_dir),
|
||||
vlm_path=_stated(settings.vlm_model_dir),
|
||||
)
|
||||
|
||||
|
||||
def _dense_facts(settings: Settings, database: Database) -> tuple[DenseState, int, int]:
|
||||
total = database.atom_count()
|
||||
if not settings.dense_embeddings_enabled:
|
||||
return "off", 0, total
|
||||
loaded = EmbeddingService(settings, database).load_index()
|
||||
if loaded is None:
|
||||
return "missing", 0, total
|
||||
covered = len(loaded.atom_ids)
|
||||
return ("current" if covered == total else "behind"), covered, total
|
||||
|
||||
|
||||
def _visual_state(visual: VisualIndex, pictures: set[str]) -> VisualState:
|
||||
"""A store of pictures the index has not encoded yet is stale: they are invisible to search."""
|
||||
if not visual.available():
|
||||
return "absent"
|
||||
if not pictures:
|
||||
return "ready"
|
||||
loaded = visual.load()
|
||||
return "ready" if loaded is not None and set(loaded[0]) == pictures else "stale"
|
||||
|
||||
|
||||
def _picture_claim(database: Database) -> int:
|
||||
"""Atoms that say they hold a picture, whether or not the bytes survived."""
|
||||
with database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT COUNT(*) FROM v_atoms_current WHERE image_ref IS NOT NULL AND image_ref != ''"
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def _sense_state(model_dir: Path | None) -> SenseState:
|
||||
"""A sense with no configured snapshot is absent; one pointing at a deleted directory is missing."""
|
||||
if model_dir is None:
|
||||
return "absent"
|
||||
return "ready" if model_dir.exists() else "missing"
|
||||
|
||||
|
||||
def _stated(model_dir: Path | None) -> str | None:
|
||||
return None if model_dir is None else str(model_dir)
|
||||
|
||||
|
||||
def _audio_count(database: Database) -> int:
|
||||
with database.connect() as connection:
|
||||
row = connection.execute("SELECT COUNT(*) AS n FROM v_atoms_current WHERE modality = 'audio'").fetchone()
|
||||
return int(row["n"])
|
||||
@@ -141,12 +141,30 @@ class VisualIndex:
|
||||
_LOADED.clear()
|
||||
return removed
|
||||
|
||||
def is_current(self, atoms: list[EvidenceAtom] | None = None) -> bool:
|
||||
"""True when the index covers exactly the image atoms the store holds right now."""
|
||||
def visual_atom_ids(self) -> set[str]:
|
||||
"""Picture atoms whose bytes are still on disk, asked with one light query."""
|
||||
store = ObjectStore(self.settings)
|
||||
with self.db.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT atom_id, image_ref FROM v_atoms_current WHERE image_ref IS NOT NULL AND image_ref != ''"
|
||||
).fetchall()
|
||||
kept: set[str] = set()
|
||||
for row in rows:
|
||||
try:
|
||||
path = store.resolve(str(row["image_ref"]))
|
||||
except ValueError:
|
||||
continue
|
||||
if path.exists():
|
||||
kept.add(str(row["atom_id"]))
|
||||
return kept
|
||||
|
||||
def is_current(self, atom_ids: set[str] | None = None) -> bool:
|
||||
"""True when the index covers exactly the picture atoms the store holds right now."""
|
||||
loaded = self.load()
|
||||
if loaded is None:
|
||||
return False
|
||||
return {atom.atom_id for atom in self.visual_atoms(atoms)} == set(loaded[0])
|
||||
wanted = self.visual_atom_ids() if atom_ids is None else atom_ids
|
||||
return wanted == set(loaded[0])
|
||||
|
||||
def search_text(self, query: str, top_k: int, restrict: set[str] | None = None) -> list[tuple[str, float]]:
|
||||
vector = self.encoder().encode_texts([query])[0]
|
||||
|
||||
@@ -19,6 +19,7 @@ from ely_eye.db import Database
|
||||
from ely_eye.memory import MemoryService
|
||||
from ely_eye.migrations import SCHEMA_VERSION
|
||||
from ely_eye.schemas import EvidenceAtom, MemoryOrigin, Modality, SourceRecord, TrustScores, utc_now
|
||||
from ely_eye.storage import ObjectStore
|
||||
|
||||
KEYS = "the keys are on the shelf by the door"
|
||||
PASSPORT = "the passport is in the blue box"
|
||||
@@ -164,7 +165,14 @@ def test_doctor_names_the_senses_a_store_holds_but_cannot_use(tmp_path: Path) ->
|
||||
)
|
||||
database.upsert_atoms(
|
||||
[
|
||||
EvidenceAtom(atom_id="img:1", modality=Modality.image, source_id="img", source="file:///img.png", trust=trust),
|
||||
EvidenceAtom(
|
||||
atom_id="img:1",
|
||||
modality=Modality.image,
|
||||
source_id="img",
|
||||
source="file:///img.png",
|
||||
trust=trust,
|
||||
image_ref=ObjectStore(settings).put_bytes(b"\x89PNG\r\n\x1a\n", ".png", "image"),
|
||||
),
|
||||
EvidenceAtom(atom_id="aud:1", modality=Modality.audio, source_id="aud", source="file:///a.mp3", trust=trust),
|
||||
]
|
||||
)
|
||||
@@ -175,6 +183,30 @@ def test_doctor_names_the_senses_a_store_holds_but_cannot_use(tmp_path: Path) ->
|
||||
assert "1 audio atom but no speech snapshot" in report.warnings[1]
|
||||
|
||||
|
||||
def test_doctor_names_picture_atoms_whose_bytes_are_gone(tmp_path: Path) -> None:
|
||||
settings = _settings(tmp_path)
|
||||
database = Database(settings)
|
||||
database.init()
|
||||
trust = TrustScores(parser=0.0, ocr=0.0, model=0.0)
|
||||
database.upsert_sources(
|
||||
[SourceRecord(source_id="img", path="file:///img.png", kind="image", mime="image/png", sha256="a", size_bytes=1)]
|
||||
)
|
||||
database.upsert_atoms(
|
||||
[
|
||||
EvidenceAtom(
|
||||
atom_id="img:1",
|
||||
modality=Modality.image,
|
||||
source_id="img",
|
||||
source="file:///img.png",
|
||||
trust=trust,
|
||||
image_ref="object://image/ab/deadbeef.png",
|
||||
)
|
||||
]
|
||||
)
|
||||
report = maintenance.doctor(settings)
|
||||
assert "1 picture atom with no bytes behind them" in report.warnings[0]
|
||||
|
||||
|
||||
def test_doctor_names_a_mount_whose_files_are_gone(tmp_path: Path) -> None:
|
||||
settings = _settings(tmp_path)
|
||||
database = _store(settings)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""The product face: page the store, look at a picture, and say plainly what the machine can perceive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import ely_eye.api as api_module
|
||||
from ely_eye.config import Settings
|
||||
from ely_eye.db import Database
|
||||
from ely_eye.memory import MemoryService
|
||||
from ely_eye.schemas import EvidenceAtom, MemoryOrigin, Modality, SourceRecord, TrustScores
|
||||
from ely_eye.storage import ObjectStore
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n"
|
||||
BASE = datetime(2026, 5, 1, 12, 0, tzinfo=UTC)
|
||||
|
||||
HTTP_SURFACE = {
|
||||
"/api/adapters",
|
||||
"/api/ask",
|
||||
"/api/atoms",
|
||||
"/api/atoms/{atom_id}/image",
|
||||
"/api/cartridges",
|
||||
"/api/cartridges/{cartridge_id}/append",
|
||||
"/api/cartridges/{cartridge_id}/assets",
|
||||
"/api/cartridges/{cartridge_id}/finalize",
|
||||
"/api/cartridges/{cartridge_id}/mount",
|
||||
"/api/cartridges/{cartridge_id}/refresh",
|
||||
"/api/cartridges/{cartridge_id}/switch",
|
||||
"/api/cartridges/{cartridge_id}/unmount",
|
||||
"/api/chat",
|
||||
"/api/hashhop",
|
||||
"/api/holders",
|
||||
"/api/holders/{holder_id}",
|
||||
"/api/holders/{holder_id}/knows",
|
||||
"/api/ingest/path",
|
||||
"/api/ingest/upload",
|
||||
"/api/memory",
|
||||
"/api/memory/forget",
|
||||
"/api/memory/supersede",
|
||||
"/api/mounts",
|
||||
"/api/proof-suite",
|
||||
"/api/proof-suite/latest",
|
||||
"/api/runtime/adapters",
|
||||
"/api/runtime/profiles",
|
||||
"/api/search",
|
||||
"/api/status",
|
||||
"/api/who_knows",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Settings:
|
||||
resolved = Settings(home=tmp_path, default_holder="wife")
|
||||
monkeypatch.setattr(api_module, "get_settings", lambda: resolved)
|
||||
Database(resolved).init()
|
||||
return resolved
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(settings: Settings) -> TestClient:
|
||||
return TestClient(api_module.create_app())
|
||||
|
||||
|
||||
def _seed(settings: Settings, pictures: int = 3) -> None:
|
||||
"""Pictures an hour apart, so newest-first has a fact to order by rather than a coincidence."""
|
||||
database = Database(settings)
|
||||
trust = TrustScores(parser=1.0, ocr=0.0, model=0.0)
|
||||
store = ObjectStore(settings)
|
||||
stored = store.put_bytes(PNG, ".png", "image")
|
||||
database.upsert_sources(
|
||||
[
|
||||
SourceRecord(
|
||||
source_id=f"shot{i}",
|
||||
path=f"file:///shot{i}.png",
|
||||
kind="image",
|
||||
mime="image/png",
|
||||
sha256=f"{i}" * 8,
|
||||
size_bytes=len(PNG),
|
||||
)
|
||||
for i in range(pictures)
|
||||
]
|
||||
)
|
||||
database.upsert_atoms(
|
||||
[
|
||||
EvidenceAtom(
|
||||
atom_id=f"shot{i}:frame",
|
||||
modality=Modality.image,
|
||||
source_id=f"shot{i}",
|
||||
source=f"file:///shot{i}.png",
|
||||
trust=trust,
|
||||
image_ref=stored,
|
||||
holder_id="wife" if i % 2 == 0 else "husband",
|
||||
ingested_at=BASE + timedelta(hours=i),
|
||||
)
|
||||
for i in range(pictures)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_the_store_pages_newest_first_and_says_how_much_is_left(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings)
|
||||
first = client.get("/api/atoms", params={"limit": 2})
|
||||
assert first.status_code == 200
|
||||
page = first.json()
|
||||
assert page["total"] == 3
|
||||
assert [atom["atom_id"] for atom in page["atoms"]] == ["shot2:frame", "shot1:frame"]
|
||||
assert page["next_cursor"]
|
||||
|
||||
second = client.get("/api/atoms", params={"limit": 2, "cursor": page["next_cursor"]})
|
||||
rest = second.json()
|
||||
assert [atom["atom_id"] for atom in rest["atoms"]] == ["shot0:frame"]
|
||||
assert rest["next_cursor"] is None, "the last page must not promise another one"
|
||||
|
||||
|
||||
def test_browse_narrows_by_modality_and_by_holder(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=2)
|
||||
MemoryService(settings, Database(settings)).remember(
|
||||
"the keys are on the shelf", holder_id="wife", origin=MemoryOrigin.self_
|
||||
)
|
||||
|
||||
images = client.get("/api/atoms", params={"modality": "image"}).json()
|
||||
assert images["total"] == 2
|
||||
assert {atom["modality"] for atom in images["atoms"]} == {"image"}
|
||||
|
||||
his = client.get("/api/atoms", params={"holder": "husband"}).json()
|
||||
assert [atom["atom_id"] for atom in his["atoms"]] == ["shot1:frame"]
|
||||
|
||||
|
||||
def test_an_unreadable_cursor_is_refused_with_400(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
response = client.get("/api/atoms", params={"cursor": "not-a-cursor"})
|
||||
assert response.status_code == 400
|
||||
assert "cursor" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_a_picture_atom_serves_the_bytes_behind_it(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
response = client.get("/api/atoms/shot0:frame/image")
|
||||
assert response.status_code == 200
|
||||
assert response.content == PNG
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
def test_a_memory_with_no_picture_says_so(client: TestClient, settings: Settings) -> None:
|
||||
atom = MemoryService(settings, Database(settings)).remember(
|
||||
"the keys are on the shelf", holder_id="wife", origin=MemoryOrigin.self_
|
||||
)
|
||||
assert client.get(f"/api/atoms/{atom.atom_id}/image").status_code == 404
|
||||
assert client.get("/api/atoms/nope:image").status_code == 404
|
||||
|
||||
|
||||
def test_a_forgotten_picture_stops_being_served(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
MemoryService(settings, Database(settings)).forget(atom_id="shot0:frame")
|
||||
assert client.get("/api/atoms/shot0:frame/image").status_code == 404
|
||||
assert client.get("/api/atoms").json()["total"] == 0
|
||||
|
||||
|
||||
def test_status_says_what_the_machine_can_see_and_what_it_cannot(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=2)
|
||||
senses = client.get("/api/status").json()["senses"]
|
||||
assert senses["dense"] == "off", "dense embeddings are off by default and status must not claim otherwise"
|
||||
assert senses["picture_atoms"] == 2
|
||||
assert senses["picture_bytes_missing"] == 0
|
||||
assert senses["visual"] == "absent", "no CLIP snapshot on this machine means wordless pictures stay hidden"
|
||||
assert senses["speech"] == "absent"
|
||||
|
||||
|
||||
def test_status_names_pictures_whose_bytes_are_gone(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
dead = Database(settings).get_atoms(["shot0:frame"])[0].model_copy(
|
||||
update={"atom_id": "shot0:ghost", "image_ref": "object://image/ab/deadbeef.png"}
|
||||
)
|
||||
Database(settings).upsert_atoms([dead])
|
||||
senses = client.get("/api/status").json()["senses"]
|
||||
assert senses["picture_atoms"] == 2
|
||||
assert senses["picture_bytes_missing"] == 1
|
||||
|
||||
|
||||
def test_the_http_surface_is_exactly_what_someone_uses() -> None:
|
||||
"""A route nobody calls is a contract nobody keeps: adding one has to be a deliberate act."""
|
||||
served = {
|
||||
route.path
|
||||
for route in api_module.app.routes
|
||||
if isinstance(route, APIRoute) and route.path.startswith("/api")
|
||||
}
|
||||
assert served == HTTP_SURFACE
|
||||
Generated
+11
-28
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "ely-eye-frontend",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
@@ -24,7 +25,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"playwright": "^1.60.0",
|
||||
"playwright": "^1.64.0",
|
||||
"typescript-eslint": "^8.50.0"
|
||||
}
|
||||
},
|
||||
@@ -2503,50 +2504,32 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
|
||||
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
"playwright-core": "1.64.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
|
||||
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"playwright": "^1.60.0",
|
||||
"playwright": "^1.64.0",
|
||||
"typescript-eslint": "^8.50.0"
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,231 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(process.cwd(), '..');
|
||||
const outputDir = path.join(root, '.ely_eye', 'proofs');
|
||||
const outputDir = process.env.ELY_EYE_UI_SMOKE_DIR ?? path.join(root, '.ely_eye', 'proofs');
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
|
||||
const apiBase = process.env.ELY_EYE_API ?? 'http://127.0.0.1:8765';
|
||||
const uiUrl = process.env.ELY_EYE_UI_URL ?? 'http://127.0.0.1:5173/';
|
||||
|
||||
async function api(route, body) {
|
||||
const response = await fetch(`${apiBase}${route}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`${route} was rejected: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function apiGet(route) {
|
||||
const response = await fetch(`${apiBase}${route}`);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`${route} was rejected: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
const statements = ['the spare glasses are in the hall drawer'];
|
||||
for (let index = 1; index < 14; index += 1) {
|
||||
statements.push(`shelf position ${index} holds the ${index} box of cables`);
|
||||
}
|
||||
for (const statement of statements) {
|
||||
await api('/api/memory', { text: statement, scenario: 'home' });
|
||||
}
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
const imagePath = path.join(outputDir, 'wordless-cup.png');
|
||||
await writeFile(imagePath, png);
|
||||
await api('/api/ingest/path', { path: imagePath, cartridge_name: 'ui-smoke' });
|
||||
// The store, not the test, decides what kind of picture a file became.
|
||||
const page = await apiGet('/api/atoms?limit=50');
|
||||
const picture = page.atoms.find((atom) => atom.image_ref);
|
||||
if (!picture) {
|
||||
throw new Error(`ingesting ${imagePath} left no atom with a picture: ${JSON.stringify(page.atoms.map((atom) => [atom.atom_id, atom.modality]))}`);
|
||||
}
|
||||
return picture.modality;
|
||||
}
|
||||
|
||||
const pictureModality = await seed();
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
|
||||
const consoleErrors = [];
|
||||
const failedRequests = [];
|
||||
page.on('pageerror', (error) => consoleErrors.push(`uncaught: ${error.message}`));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
if (message.type() === 'error' && !message.text().startsWith('Failed to load resource')) {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
function routeOf(url) {
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
await page.goto('http://127.0.0.1:5173/', { waitUntil: 'networkidle' });
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
failedRequests.push(`${response.status()} ${routeOf(response.url())}`);
|
||||
}
|
||||
});
|
||||
// A store that has never run the proof suite answers 404, and the panel says "pending".
|
||||
const expectedFailures = ['404 /api/proof-suite/latest'];
|
||||
|
||||
const observed = { mount: 'unknown', adapters: 'unknown' };
|
||||
|
||||
async function evidence(label) {
|
||||
await page.screenshot({ path: path.join(outputDir, 'ui-failure.png'), fullPage: true }).catch(() => undefined);
|
||||
const text = await page
|
||||
.locator('.panel', { has: page.getByRole('heading', { name: 'Memory' }) })
|
||||
.first()
|
||||
.innerText()
|
||||
.catch(() => '(the memory panel is gone)');
|
||||
const served = await fetch(`${apiBase}/api/atoms?limit=5&modality=image`)
|
||||
.then((response) => response.json())
|
||||
.catch((err) => ({ error: String(err) }));
|
||||
console.error(
|
||||
`${label}\n--- memory panel ---\n${text}\n--- what the API serves ---\n${JSON.stringify(served, null, 2)}\n--- screenshot ---\n${path.join(outputDir, 'ui-failure.png')}`,
|
||||
);
|
||||
}
|
||||
|
||||
await page.goto(uiUrl, { waitUntil: 'networkidle' });
|
||||
await page.getByRole('heading', { name: 'Local VLM Context OS' }).waitFor();
|
||||
await page.getByLabel('system status').getByText('Qwen/Qwen3.5-9B').waitFor();
|
||||
await page.getByRole('heading', { name: 'Adapter Matrix' }).waitFor();
|
||||
await page.getByRole('heading', { name: 'PRD Proof Suite' }).waitFor();
|
||||
await page.getByText(/\d+\/\d+ passed/).waitFor();
|
||||
await page.getByText(/(\d+\/\d+ passed|pending)/).waitFor();
|
||||
|
||||
// 1. search: ask what someone knows, in a scenario.
|
||||
await page.getByPlaceholder('where are the keys').fill('spare glasses');
|
||||
await page.getByPlaceholder('any').nth(1).fill('home');
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
const hit = page.locator('.atom-row', { hasText: 'spare glasses are in the hall drawer' }).first();
|
||||
await hit.waitFor();
|
||||
const hitText = await hit.innerText();
|
||||
if (!hitText.includes('home') || !hitText.includes('wife')) {
|
||||
throw new Error(`the search row does not name who knows it and in what setting: ${hitText}`);
|
||||
}
|
||||
|
||||
// 2. browse: page the store and look at a wordless picture.
|
||||
await page.getByLabel('Modality').selectOption(pictureModality);
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const image = document.querySelector('img.thumb');
|
||||
return Boolean(image) && image.complete && image.naturalWidth > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
} catch (error) {
|
||||
await evidence('the picture atom never rendered pixels in the browse list');
|
||||
throw error;
|
||||
}
|
||||
await page.getByLabel('Modality').selectOption('');
|
||||
const firstPageRows = await page.locator('.atom-list .atom-row').count();
|
||||
await page.getByTitle('Next page').click();
|
||||
await page.waitForFunction(
|
||||
(count) => document.querySelectorAll('.atom-list .atom-row').length !== count,
|
||||
firstPageRows,
|
||||
);
|
||||
await page.getByTitle('Previous page').click();
|
||||
|
||||
// 3. forget: remove a memory and watch it leave the store.
|
||||
await page.getByPlaceholder('where are the keys').fill('spare glasses');
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
await page.locator('.atom-row', { hasText: 'spare glasses are in the hall drawer' }).first().waitFor();
|
||||
await page
|
||||
.locator('.atom-row', { hasText: 'spare glasses are in the hall drawer' })
|
||||
.first()
|
||||
.getByTitle('Forget this memory')
|
||||
.click();
|
||||
await page.getByText(/forgotten: \d+ atom/).waitFor();
|
||||
await page.getByPlaceholder('where are the keys').fill('spare glasses');
|
||||
await page.getByRole('button', { name: 'Search', exact: true }).click();
|
||||
await page.getByText(/Nothing in the store answers/).waitFor();
|
||||
|
||||
// 4. mount: a cartridge is a scope you mount, and the machine decides if it may.
|
||||
const mountRow = page.locator('.adapter-row', { hasText: 'ui-smoke' }).first();
|
||||
await mountRow.waitFor();
|
||||
await mountRow.getByRole('button', { name: 'Mount', exact: true }).click();
|
||||
const mountedPill = mountRow.locator('.proof-pill.passed', { hasText: 'mounted' });
|
||||
const refusal = page.locator('.error-line').first();
|
||||
if (await mountedPill.isVisible().catch(() => false)) {
|
||||
observed.mount = 'accepted';
|
||||
await mountRow.getByRole('button', { name: 'Unmount', exact: true }).click();
|
||||
await page.getByText(/unmount ui-smoke/).waitFor();
|
||||
} else {
|
||||
await refusal.waitFor();
|
||||
observed.mount = `refused: ${(await refusal.innerText()).slice(0, 120)}`;
|
||||
const cartridges = await apiGet('/api/cartridges');
|
||||
const cartridge = cartridges.find((entry) => entry.name === 'ui-smoke');
|
||||
if (!cartridge) {
|
||||
throw new Error('the ui-smoke cartridge vanished from the store while the console was mounting it');
|
||||
}
|
||||
expectedFailures.push(`400 /api/cartridges/${cartridge.cartridge_id}/mount`);
|
||||
}
|
||||
|
||||
// 5. adapter states: the panel says what is attached and why the rest is not.
|
||||
await page.getByRole('heading', { name: 'Adapter Matrix' }).scrollIntoViewIfNeeded();
|
||||
const loadStrip = page
|
||||
.locator('.panel', { has: page.getByRole('heading', { name: 'Adapter Matrix' }) })
|
||||
.first();
|
||||
await loadStrip.getByText(/\d+ mounted/).waitFor();
|
||||
const states = await loadStrip.locator('.layer-row').evaluateAll((rows) =>
|
||||
rows.map((row) => [
|
||||
row.querySelector('strong')?.textContent ?? '',
|
||||
(row.querySelector('small')?.textContent ?? '').trim(),
|
||||
]),
|
||||
);
|
||||
if (states.length === 0) {
|
||||
await evidence('the adapter panel named no adapter kind at all');
|
||||
throw new Error('the adapter panel reported no states; the load report was empty');
|
||||
}
|
||||
for (const [state, reason] of states) {
|
||||
if (!/^(loaded|available|deferred|missing)$/.test(state)) {
|
||||
throw new Error(`an adapter row reported the state "${state}"`);
|
||||
}
|
||||
if (!reason) {
|
||||
throw new Error(`the adapter row "${state}" gave no reason`);
|
||||
}
|
||||
}
|
||||
observed.adapters = `${states.length} kinds: ${states.map(([state]) => state).join(', ')}`;
|
||||
|
||||
await page.screenshot({ path: path.join(outputDir, 'ely-eye-dashboard.png'), fullPage: true });
|
||||
const status = await page.locator('.kpi-grid').innerText();
|
||||
await browser.close();
|
||||
|
||||
if (consoleErrors.length > 0) {
|
||||
throw new Error(consoleErrors.join('\n'));
|
||||
const unexpected = failedRequests.filter(
|
||||
(line) => !expectedFailures.some((allowed) => line === allowed || line.startsWith(allowed)),
|
||||
);
|
||||
if (unexpected.length > 0 || consoleErrors.length > 0) {
|
||||
throw new Error(
|
||||
[`${unexpected.length} unexpected failed request(s): ${unexpected.join(', ')}`, ...consoleErrors].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ screenshot: path.join(outputDir, 'ely-eye-dashboard.png'), status }, null, 2));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
screenshot: path.join(outputDir, 'ely-eye-dashboard.png'),
|
||||
mount: observed.mount,
|
||||
adapters: observed.adapters,
|
||||
expected_failures: expectedFailures.filter((line) => failedRequests.includes(line)),
|
||||
status,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
+63
-449
@@ -1,96 +1,37 @@
|
||||
import {
|
||||
Activity,
|
||||
Archive,
|
||||
Braces,
|
||||
CheckCircle,
|
||||
Cpu,
|
||||
Database,
|
||||
FileSearch,
|
||||
Gauge,
|
||||
GitBranch,
|
||||
Image as ImageIcon,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { FormEvent, ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
askQuestion,
|
||||
getAdapters,
|
||||
getLatestProofSuite,
|
||||
getMemoryMap,
|
||||
getStatus,
|
||||
ingestPath,
|
||||
runHashHop,
|
||||
runProofSuite,
|
||||
uploadFiles,
|
||||
} from './api';
|
||||
import type {
|
||||
AdapterStatus,
|
||||
AnswerResponse,
|
||||
HashHopProof,
|
||||
IngestResponse,
|
||||
MemoryMap,
|
||||
ProofSuiteReport,
|
||||
RuntimeProfile,
|
||||
SystemStatus,
|
||||
} from './types';
|
||||
import { Activity, Archive, Database, Gauge, RefreshCw, Server } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const profiles: Array<{ id: RuntimeProfile; label: string }> = [
|
||||
{ id: 'live_demo', label: 'Live Demo' },
|
||||
{ id: 'extreme_context', label: 'Extreme' },
|
||||
{ id: 'library_100m', label: '100M Library' },
|
||||
{ id: 'research_theater', label: 'Research' },
|
||||
];
|
||||
import { getAdapters, getStatus } from './api';
|
||||
import { AdapterPanel } from './components/AdapterPanel';
|
||||
import { CartridgePanel } from './components/CartridgePanel';
|
||||
import { CompilerPanel } from './components/CompilerPanel';
|
||||
import { IngestPanel } from './components/IngestPanel';
|
||||
import { MemoryPanel } from './components/MemoryPanel';
|
||||
import { ProofPanel } from './components/ProofPanel';
|
||||
import { SensesPanel } from './components/SensesPanel';
|
||||
import { Kpi, Panel, Quiet } from './components/ui';
|
||||
import { errorMessage, formatBytes, formatNumber, formatRuntimeDetail, formatVram } from './lib/format';
|
||||
import type { AdapterStatus, CacheLayerStatus, SystemStatus } from './types';
|
||||
|
||||
export function App() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [memoryMap, setMemoryMap] = useState<MemoryMap | null>(null);
|
||||
const [adapters, setAdapters] = useState<AdapterStatus[]>([]);
|
||||
const [profile, setProfile] = useState<RuntimeProfile>('library_100m');
|
||||
const [path, setPath] = useState('');
|
||||
const [cartridgeName, setCartridgeName] = useState('ely-eye-cartridge');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [answer, setAnswer] = useState<AnswerResponse | null>(null);
|
||||
const [hashHop, setHashHop] = useState<HashHopProof | null>(null);
|
||||
const [proofSuite, setProofSuite] = useState<ProofSuiteReport | null>(null);
|
||||
const [lastIngest, setLastIngest] = useState<IngestResponse | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
setError(null);
|
||||
const [nextStatus, nextMap, nextAdapters, nextProofSuite] = await Promise.all([
|
||||
getStatus(),
|
||||
getMemoryMap(),
|
||||
getAdapters(),
|
||||
getLatestProofSuite().catch(() => null),
|
||||
]);
|
||||
async function refresh() {
|
||||
const [nextStatus, nextAdapters] = await Promise.all([getStatus(), getAdapters()]);
|
||||
setStatus(nextStatus);
|
||||
setMemoryMap(nextMap);
|
||||
setAdapters(nextAdapters);
|
||||
setProofSuite(nextProofSuite);
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadInitial() {
|
||||
try {
|
||||
const [nextStatus, nextMap, nextAdapters, nextProofSuite] = await Promise.all([
|
||||
getStatus(),
|
||||
getMemoryMap(),
|
||||
getAdapters(),
|
||||
getLatestProofSuite().catch(() => null),
|
||||
]);
|
||||
const [nextStatus, nextAdapters] = await Promise.all([getStatus(), getAdapters()]);
|
||||
if (!cancelled) {
|
||||
setStatus(nextStatus);
|
||||
setMemoryMap(nextMap);
|
||||
setAdapters(nextAdapters);
|
||||
setProofSuite(nextProofSuite);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
@@ -104,86 +45,16 @@ export function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const memoryLayers = useMemo(() => status?.memory.layers ?? [], [status]);
|
||||
const boundAdapters = useMemo(() => adapters.filter((adapter) => adapter.cartridge_bound).length, [adapters]);
|
||||
const passedProofChecks = useMemo(
|
||||
() => proofSuite?.checks.filter((check) => check.status === 'passed').length ?? 0,
|
||||
[proofSuite],
|
||||
);
|
||||
|
||||
async function submitPath(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy('ingest-path');
|
||||
setError(null);
|
||||
async function refreshQuietly() {
|
||||
try {
|
||||
const result = await ingestPath(path, cartridgeName);
|
||||
setLastIngest(result);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpload(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy('upload');
|
||||
setError(null);
|
||||
try {
|
||||
const result = await uploadFiles(files, cartridgeName);
|
||||
setLastIngest(result);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitQuestion(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setBusy('chat');
|
||||
setError(null);
|
||||
try {
|
||||
const result = await askQuestion(question, profile);
|
||||
setAnswer(result);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitHashHop(kind: 'text' | 'visual') {
|
||||
setBusy(`hashhop-${kind}`);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runHashHop(kind, kind === 'visual' ? 2 : 2, kind === 'visual' ? 262144 : 1010000);
|
||||
setHashHop(result);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProofSuite() {
|
||||
setBusy('proof-suite');
|
||||
setError(null);
|
||||
try {
|
||||
const cartridgeId = memoryMap?.cartridges[0]?.cartridge_id;
|
||||
const result = await runProofSuite(cartridgeId);
|
||||
setProofSuite(result);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
const layers: CacheLayerStatus[] = status?.memory.layers ?? [];
|
||||
const boundAdapters = adapters.filter((adapter) => adapter.cartridge_bound).length;
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
@@ -192,7 +63,11 @@ export function App() {
|
||||
<p className="eyebrow">Ely-Eye v4.0</p>
|
||||
<h1>Local VLM Context OS</h1>
|
||||
</div>
|
||||
<button className="icon-button" title="Refresh status" onClick={() => refresh().catch((err) => setError(errorMessage(err)))}>
|
||||
<button
|
||||
className="icon-button"
|
||||
title="Refresh status"
|
||||
onClick={() => refreshQuietly()}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</button>
|
||||
</header>
|
||||
@@ -200,318 +75,57 @@ export function App() {
|
||||
{error && <div className="error-line">{error}</div>}
|
||||
|
||||
<section className="kpi-grid" aria-label="system status">
|
||||
<Kpi icon={<Server size={18} />} label="Runtime" value={status?.runtime.available ? 'ready' : 'offline'} detail={formatRuntimeDetail(status)} />
|
||||
<Kpi icon={<Gauge size={18} />} label="GPU" value={status?.runtime.gpu_name ?? 'unknown'} detail={formatVram(status)} />
|
||||
<Kpi icon={<Database size={18} />} label="Library" value={formatNumber(status?.memory.library_token_equivalent ?? 0)} detail="token eq" />
|
||||
<Kpi icon={<Archive size={18} />} label="Cartridges" value={String(status?.memory.cartridges ?? 0)} detail={`${boundAdapters} adapters bound`} />
|
||||
<Kpi
|
||||
icon={<Server size={18} />}
|
||||
label="Runtime"
|
||||
value={status?.runtime.available ? 'ready' : 'offline'}
|
||||
detail={formatRuntimeDetail(status)}
|
||||
/>
|
||||
<Kpi
|
||||
icon={<Gauge size={18} />}
|
||||
label="GPU"
|
||||
value={status?.runtime.gpu_name ?? 'unknown'}
|
||||
detail={formatVram(status)}
|
||||
/>
|
||||
<Kpi
|
||||
icon={<Database size={18} />}
|
||||
label="Library"
|
||||
value={formatNumber(status?.memory.library_token_equivalent ?? 0)}
|
||||
detail={`${formatNumber(status?.memory.active_token_equivalent ?? 0)} token eq open`}
|
||||
/>
|
||||
<Kpi
|
||||
icon={<Archive size={18} />}
|
||||
label="Cartridges"
|
||||
value={String(status?.memory.cartridges ?? 0)}
|
||||
detail={`${boundAdapters} adapters bound`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="workspace-grid">
|
||||
<Panel title="Input Stream" icon={<Upload size={17} />}>
|
||||
<form className="stack" onSubmit={submitPath}>
|
||||
<label>
|
||||
Cartridge
|
||||
<input value={cartridgeName} onChange={(event) => setCartridgeName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Local path
|
||||
<input value={path} onChange={(event) => setPath(event.target.value)} />
|
||||
</label>
|
||||
<button className="action-button" disabled={!path || busy === 'ingest-path'}>
|
||||
<FileSearch size={16} />
|
||||
Ingest Path
|
||||
</button>
|
||||
</form>
|
||||
<form className="stack" onSubmit={submitUpload}>
|
||||
<label>
|
||||
Files
|
||||
<input type="file" multiple onChange={(event) => setFiles(Array.from(event.target.files ?? []))} />
|
||||
</label>
|
||||
<button className="action-button secondary" disabled={files.length === 0 || busy === 'upload'}>
|
||||
<Upload size={16} />
|
||||
Upload Files
|
||||
</button>
|
||||
</form>
|
||||
{lastIngest && (
|
||||
<div className="metric-strip">
|
||||
<span>{lastIngest.source_count} sources</span>
|
||||
<span>{lastIngest.atom_count} atoms</span>
|
||||
<span>{formatNumber(lastIngest.token_equivalent)} tokens</span>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Context Compiler" icon={<Braces size={17} />}>
|
||||
<div className="segments">
|
||||
{profiles.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={profile === item.id ? 'segment active' : 'segment'}
|
||||
onClick={() => setProfile(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form className="stack" onSubmit={submitQuestion}>
|
||||
<textarea value={question} onChange={(event) => setQuestion(event.target.value)} rows={6} />
|
||||
<button className="action-button" disabled={!question || busy === 'chat'}>
|
||||
<Send size={16} />
|
||||
Ask
|
||||
</button>
|
||||
</form>
|
||||
{answer && (
|
||||
<div className="answer-box">
|
||||
<p>{answer.answer}</p>
|
||||
<div className="metric-strip">
|
||||
<span>{answer.context.hits.length} hits</span>
|
||||
<span>{Math.round(answer.verifier.citation_accuracy * 100)}% citations</span>
|
||||
<span>{answer.runtime_backend}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Memory OS" icon={<Database size={17} />}>
|
||||
<div className="layer-list">
|
||||
{memoryLayers.map((layer) => (
|
||||
<div className="layer-row" key={layer.layer}>
|
||||
<span>{layer.layer}</span>
|
||||
<strong>{layer.entries}</strong>
|
||||
<small>{layer.hit_count} hit / {layer.miss_count} miss</small>
|
||||
</div>
|
||||
))}
|
||||
{memoryLayers.length === 0 && <div className="quiet-line">No cache events</div>}
|
||||
</div>
|
||||
<div className="atom-list">
|
||||
{(memoryMap?.atoms ?? []).slice(0, 8).map((atom) => (
|
||||
<div className="atom-row" key={atom.atom_id}>
|
||||
<span>{atom.modality}</span>
|
||||
<strong>{atom.atom_id}</strong>
|
||||
<small>{atom.source}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Adapter Matrix" icon={<Cpu size={17} />}>
|
||||
<div className="adapter-list">
|
||||
{adapters.map((adapter) => (
|
||||
<div className="adapter-row" key={adapter.adapter_id}>
|
||||
<header>
|
||||
<span>{adapter.kind.replaceAll('_', '-')}</span>
|
||||
{adapter.cartridge_bound && <CheckCircle size={16} aria-label="bound" />}
|
||||
</header>
|
||||
<strong>{adapter.base_model}</strong>
|
||||
<div className="adapter-metrics">
|
||||
<small>{adapter.training_method}</small>
|
||||
<small>{adapter.precision}</small>
|
||||
<small title={adapter.unsloth_model_type ?? adapter.framework}>{formatFramework(adapter.framework)}</small>
|
||||
<small title={adapter.optimizer_family ?? adapter.optimizer ?? undefined}>{formatOptimizer(adapter)}</small>
|
||||
<small title={adapter.gradient_checkpointing_mode ?? undefined}>{formatCheckpointing(adapter)}</small>
|
||||
<small title={adapter.triton_version ?? undefined}>{adapter.triton_version ? `triton ${adapter.triton_version}` : 'triton pending'}</small>
|
||||
<small>{formatBytes(adapter.weight_bytes)}</small>
|
||||
<small>{formatNumber(adapter.trainable_params)} params</small>
|
||||
<small>{adapter.final_loss?.toFixed(3) ?? 'loss pending'}</small>
|
||||
<small>{adapter.training_trace_steps}/{adapter.max_steps} trace</small>
|
||||
<small>{formatNumber(adapter.optimizer_update_count)} updates</small>
|
||||
<small>{adapter.loss_delta == null ? 'loss delta pending' : `loss delta ${adapter.loss_delta.toFixed(3)}`}</small>
|
||||
<small>{adapter.bf16 ? 'cuda bf16' : 'precision pending'}</small>
|
||||
<small>{formatNumber(adapter.adapter_lora_tensor_count)} LoRA tensors</small>
|
||||
<small>{formatNumber(adapter.adapter_nonzero_elements)} nonzero</small>
|
||||
<small>{adapter.adapter_weights_finite ? 'finite weights' : 'weights pending'}</small>
|
||||
<small>{adapter.adapter_weight_sha256 ? `sha ${shortHash(adapter.adapter_weight_sha256)}` : 'sha pending'}</small>
|
||||
<small>{formatDuration(adapter.training_wall_seconds)}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{adapters.length === 0 && <div className="quiet-line">No adapters found</div>}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="PRD Proof Suite" icon={<ShieldCheck size={17} />}>
|
||||
<div className="proof-suite-header">
|
||||
<button className="action-button secondary" onClick={submitProofSuite} disabled={busy === 'proof-suite'}>
|
||||
<Play size={16} />
|
||||
Run Suite
|
||||
</button>
|
||||
<div className={proofSuite?.status === 'passed' ? 'proof-pill passed' : 'proof-pill'}>
|
||||
{proofSuite ? `${passedProofChecks}/${proofSuite.checks.length} passed` : 'pending'}
|
||||
</div>
|
||||
</div>
|
||||
{proofSuite && (
|
||||
<div className="proof-list">
|
||||
{proofSuite.checks.map((check) => (
|
||||
<article className="proof-row" key={check.name}>
|
||||
<header>
|
||||
<strong>{check.name}</strong>
|
||||
<span className={check.status === 'passed' ? 'proof-status passed' : 'proof-status'}>
|
||||
{check.status}
|
||||
</span>
|
||||
</header>
|
||||
<p>{check.detail}</p>
|
||||
<small>{check.evidence[0]}</small>
|
||||
</article>
|
||||
))}
|
||||
<div className="proof-box">
|
||||
<strong>{proofSuite.proof_id}</strong>
|
||||
<span>{proofSuite.status}</span>
|
||||
<small>{proofSuite.cartridge_id}</small>
|
||||
<small>{proofSuite.dna_after ?? proofSuite.dna_before}</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!proofSuite && <div className="quiet-line">Proof suite has not run yet</div>}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Evidence Viewer" icon={<ImageIcon size={17} />}>
|
||||
<div className="evidence-list">
|
||||
{(answer?.context.hits ?? []).slice(0, 8).map((hit) => (
|
||||
<article key={hit.atom.atom_id} className="evidence-row">
|
||||
<header>
|
||||
<span>{hit.atom.modality}</span>
|
||||
<strong>{hit.final_score.toFixed(3)}</strong>
|
||||
</header>
|
||||
<p>{hit.atom.text.slice(0, 280)}</p>
|
||||
<small>{hit.atom.source}</small>
|
||||
</article>
|
||||
))}
|
||||
{!answer && <div className="quiet-line">Evidence appears after a compiled answer</div>}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Visual HashHop Arena" icon={<GitBranch size={17} />}>
|
||||
<div className="button-row">
|
||||
<button className="action-button secondary" onClick={() => submitHashHop('visual')} disabled={busy === 'hashhop-visual'}>
|
||||
<Play size={16} />
|
||||
Visual 2-hop
|
||||
</button>
|
||||
<button className="action-button secondary" onClick={() => submitHashHop('text')} disabled={busy === 'hashhop-text'}>
|
||||
<Play size={16} />
|
||||
Text 2-hop
|
||||
</button>
|
||||
</div>
|
||||
{hashHop && (
|
||||
<div className="proof-box">
|
||||
<strong>{hashHop.proof_id}</strong>
|
||||
<span>{hashHop.kind}</span>
|
||||
<span>{hashHop.hops} hops</span>
|
||||
<span>{formatNumber(hashHop.token_equivalent)} token eq</span>
|
||||
<small>{hashHop.expected_target_id}</small>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<MemoryPanel onChanged={refreshQuietly} />
|
||||
<CartridgePanel onChanged={refreshQuietly} />
|
||||
<CompilerPanel onChanged={refreshQuietly} />
|
||||
<IngestPanel onChanged={refreshQuietly} />
|
||||
<SensesPanel senses={status?.senses ?? null} />
|
||||
<Panel title="Cache Trace" icon={<Activity size={17} />}>
|
||||
<div className="trace-list">
|
||||
{memoryLayers.map((layer) => (
|
||||
{layers.map((layer) => (
|
||||
<div key={layer.layer} className="trace-row">
|
||||
<span>{layer.layer}</span>
|
||||
<meter min={0} max={Math.max(1, layer.hit_count + layer.miss_count)} value={layer.hit_count} />
|
||||
<meter
|
||||
min={0}
|
||||
max={Math.max(1, layer.hit_count + layer.miss_count)}
|
||||
value={layer.hit_count}
|
||||
/>
|
||||
<small>{formatBytes(layer.bytes_estimate)}</small>
|
||||
</div>
|
||||
))}
|
||||
{memoryLayers.length === 0 && <div className="quiet-line">Waiting for retrieval</div>}
|
||||
{layers.length === 0 && <Quiet>Waiting for retrieval</Quiet>}
|
||||
</div>
|
||||
</Panel>
|
||||
<AdapterPanel adapters={adapters} />
|
||||
<ProofPanel />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, icon, children }: { title: string; icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<header className="panel-header">
|
||||
<span className="panel-icon">{icon}</span>
|
||||
<h2>{title}</h2>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({ icon, label, value, detail }: { icon: ReactNode; label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<section className="kpi">
|
||||
<span className="panel-icon">{icon}</span>
|
||||
<div>
|
||||
<p>{label}</p>
|
||||
<strong>{value}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
if (value < 1024 * 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function formatDuration(value: number | null): string {
|
||||
if (typeof value !== 'number') {
|
||||
return 'time pending';
|
||||
}
|
||||
if (value < 60) {
|
||||
return `${value.toFixed(1)}s`;
|
||||
}
|
||||
return `${Math.round(value / 60)}m`;
|
||||
}
|
||||
|
||||
function shortHash(value: string): string {
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
function formatOptimizer(adapter: AdapterStatus): string {
|
||||
if (adapter.optimizer === 'paged_adamw_8bit') {
|
||||
return 'paged 8-bit AdamW';
|
||||
}
|
||||
return adapter.optimizer_family ?? adapter.optimizer ?? 'optimizer pending';
|
||||
}
|
||||
|
||||
function formatFramework(value: string): string {
|
||||
if (value === 'unsloth_patched_transformers_peft') {
|
||||
return 'Unsloth patch + PEFT';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatCheckpointing(adapter: AdapterStatus): string {
|
||||
if (adapter.gradient_checkpointing_mode === 'unsloth') {
|
||||
return 'ckpt unsloth';
|
||||
}
|
||||
return adapter.gradient_checkpointing ? 'ckpt on' : 'ckpt pending';
|
||||
}
|
||||
|
||||
function formatRuntimeDetail(status: SystemStatus | null): string {
|
||||
const model = status?.runtime.model_id ?? 'Qwen/Qwen3.5-9B';
|
||||
const adapter = status?.runtime.adapter_kind?.replaceAll('_', '-');
|
||||
return adapter ? `${model} · ${adapter}` : model;
|
||||
}
|
||||
|
||||
function formatVram(status: SystemStatus | null): string {
|
||||
const total = status?.runtime.vram_total_mb;
|
||||
const used = status?.runtime.vram_used_mb;
|
||||
if (typeof total === 'number' && typeof used === 'number') {
|
||||
return `${used} / ${total} MiB`;
|
||||
}
|
||||
return status?.runtime.cuda ?? 'CUDA';
|
||||
}
|
||||
+84
-5
@@ -1,11 +1,18 @@
|
||||
import type {
|
||||
AdapterLoadReport,
|
||||
AdapterStatus,
|
||||
AnswerResponse,
|
||||
AtomPage,
|
||||
CartridgeManifest,
|
||||
ForgetReport,
|
||||
HashHopProof,
|
||||
IngestResponse,
|
||||
MemoryMap,
|
||||
MountState,
|
||||
ProofSuiteReport,
|
||||
RuntimeProfile,
|
||||
RuntimeProfileReport,
|
||||
SearchFilters,
|
||||
SearchResult,
|
||||
SystemStatus,
|
||||
} from './types';
|
||||
|
||||
@@ -22,14 +29,18 @@ export function getStatus(): Promise<SystemStatus> {
|
||||
return request<SystemStatus>('/api/status');
|
||||
}
|
||||
|
||||
export function getMemoryMap(): Promise<MemoryMap> {
|
||||
return request<MemoryMap>('/api/memory-map');
|
||||
}
|
||||
|
||||
export function getAdapters(): Promise<AdapterStatus[]> {
|
||||
return request<AdapterStatus[]>('/api/adapters');
|
||||
}
|
||||
|
||||
export function getRuntimeAdapters(): Promise<AdapterLoadReport> {
|
||||
return request<AdapterLoadReport>('/api/runtime/adapters');
|
||||
}
|
||||
|
||||
export function getRuntimeProfiles(): Promise<RuntimeProfileReport> {
|
||||
return request<RuntimeProfileReport>('/api/runtime/profiles');
|
||||
}
|
||||
|
||||
export function ingestPath(path: string, cartridgeName: string): Promise<IngestResponse> {
|
||||
return request<IngestResponse>('/api/ingest/path', {
|
||||
method: 'POST',
|
||||
@@ -61,6 +72,74 @@ export function askQuestion(question: string, profile: RuntimeProfile): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export function search(query: string, filters: SearchFilters = {}, topK = 8): Promise<SearchResult> {
|
||||
const compact: SearchFilters = {};
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value !== '' && value !== null && value !== undefined) {
|
||||
compact[key as keyof SearchFilters] = value;
|
||||
}
|
||||
}
|
||||
return request<SearchResult>('/api/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, top_k: topK, filters: compact }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listAtoms(options: { limit?: number; cursor?: string; modality?: string; holder?: string } = {}): Promise<AtomPage> {
|
||||
const query = new URLSearchParams({ limit: String(options.limit ?? 12) });
|
||||
if (options.cursor) {
|
||||
query.set('cursor', options.cursor);
|
||||
}
|
||||
if (options.modality) {
|
||||
query.set('modality', options.modality);
|
||||
}
|
||||
if (options.holder) {
|
||||
query.set('holder', options.holder);
|
||||
}
|
||||
return request<AtomPage>(`/api/atoms?${query.toString()}`);
|
||||
}
|
||||
|
||||
export function atomImageUrl(atomId: string): string {
|
||||
return `/api/atoms/${encodeURIComponent(atomId)}/image`;
|
||||
}
|
||||
|
||||
export function remember(text: string, scenario: string): Promise<unknown> {
|
||||
return request<unknown>('/api/memory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, scenario: scenario || null }),
|
||||
});
|
||||
}
|
||||
|
||||
export function forget(atomId: string, reason = ''): Promise<ForgetReport> {
|
||||
return request<ForgetReport>('/api/memory/forget', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ atom_id: atomId, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listCartridges(): Promise<CartridgeManifest[]> {
|
||||
return request<CartridgeManifest[]>('/api/cartridges');
|
||||
}
|
||||
|
||||
export function getMounts(): Promise<MountState> {
|
||||
return request<MountState>('/api/mounts');
|
||||
}
|
||||
|
||||
export function mountCartridge(cartridgeId: string): Promise<CartridgeManifest> {
|
||||
return request<CartridgeManifest>(`/api/cartridges/${encodeURIComponent(cartridgeId)}/mount`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function switchCartridge(cartridgeId: string): Promise<CartridgeManifest> {
|
||||
return request<CartridgeManifest>(`/api/cartridges/${encodeURIComponent(cartridgeId)}/switch`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function unmountCartridge(cartridgeId: string): Promise<MountState> {
|
||||
return request<MountState>(`/api/cartridges/${encodeURIComponent(cartridgeId)}/unmount`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function runHashHop(kind: 'text' | 'visual', hops: number, tokenEquivalent: number): Promise<HashHopProof> {
|
||||
return request<HashHopProof>('/api/hashhop', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { CheckCircle, Cpu } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getRuntimeAdapters } from '../api';
|
||||
import type { AdapterLoadReport, AdapterStatus } from '../types';
|
||||
import {
|
||||
errorMessage,
|
||||
formatBytes,
|
||||
formatCheckpointing,
|
||||
formatDuration,
|
||||
formatFramework,
|
||||
formatNumber,
|
||||
formatOptimizer,
|
||||
shortHash,
|
||||
} from '../lib/format';
|
||||
import { Panel, Quiet } from './ui';
|
||||
|
||||
export function AdapterPanel({ adapters }: { adapters: AdapterStatus[] }) {
|
||||
const [load, setLoad] = useState<AdapterLoadReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadStates() {
|
||||
try {
|
||||
const next = await getRuntimeAdapters();
|
||||
if (!cancelled) {
|
||||
setLoad(next);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadStates();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Panel title="Adapter Matrix" icon={<Cpu size={17} />}>
|
||||
{load && (
|
||||
<div className="metric-strip">
|
||||
<span>{load.model_id}</span>
|
||||
<span>{load.mounted.length} mounted</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="layer-list">
|
||||
{(load?.adapters ?? []).map((choice) => (
|
||||
<div className="layer-row" key={choice.kind}>
|
||||
<span>{choice.kind.replaceAll('_', '-')}</span>
|
||||
<strong>{choice.state}</strong>
|
||||
<small title={choice.path ?? undefined}>{choice.reason}</small>
|
||||
</div>
|
||||
))}
|
||||
{!load && <Quiet>Load states unavailable.</Quiet>}
|
||||
</div>
|
||||
<div className="adapter-list">
|
||||
{adapters.map((adapter) => (
|
||||
<div className="adapter-row" key={adapter.adapter_id}>
|
||||
<header>
|
||||
<span>{adapter.kind.replaceAll('_', '-')}</span>
|
||||
{adapter.cartridge_bound && <CheckCircle size={16} aria-label="bound" />}
|
||||
</header>
|
||||
<strong>{adapter.base_model}</strong>
|
||||
<div className="adapter-metrics">
|
||||
<small>{adapter.training_method}</small>
|
||||
<small>{adapter.precision}</small>
|
||||
<small title={adapter.unsloth_model_type ?? adapter.framework ?? undefined}>
|
||||
{formatFramework(adapter.framework)}
|
||||
</small>
|
||||
<small title={adapter.optimizer_family ?? adapter.optimizer ?? undefined}>
|
||||
{formatOptimizer(adapter)}
|
||||
</small>
|
||||
<small title={adapter.gradient_checkpointing_mode ?? undefined}>{formatCheckpointing(adapter)}</small>
|
||||
<small title={adapter.triton_version ?? undefined}>
|
||||
{adapter.triton_version ? `triton ${adapter.triton_version}` : 'triton pending'}
|
||||
</small>
|
||||
<small>{formatBytes(adapter.weight_bytes)}</small>
|
||||
<small>{formatNumber(adapter.trainable_params)} params</small>
|
||||
<small>{adapter.final_loss?.toFixed(3) ?? 'loss pending'}</small>
|
||||
<small>
|
||||
{adapter.training_trace_steps}/{adapter.max_steps} trace
|
||||
</small>
|
||||
<small>{formatNumber(adapter.optimizer_update_count)} updates</small>
|
||||
<small>
|
||||
{adapter.loss_delta == null ? 'loss delta pending' : `loss delta ${adapter.loss_delta.toFixed(3)}`}
|
||||
</small>
|
||||
<small>{adapter.bf16 ? 'cuda bf16' : 'precision pending'}</small>
|
||||
<small>{formatNumber(adapter.adapter_lora_tensor_count)} LoRA tensors</small>
|
||||
<small>{formatNumber(adapter.adapter_nonzero_elements)} nonzero</small>
|
||||
<small>{adapter.adapter_weights_finite ? 'finite weights' : 'weights pending'}</small>
|
||||
<small>
|
||||
{adapter.adapter_weight_sha256 ? `sha ${shortHash(adapter.adapter_weight_sha256)}` : 'sha pending'}
|
||||
</small>
|
||||
<small>{formatDuration(adapter.training_wall_seconds)}</small>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{adapters.length === 0 && <Quiet>No adapters found</Quiet>}
|
||||
</div>
|
||||
{error && <div className="error-line">{error}</div>}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Archive, Play, Square, SwitchCamera } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getMounts, listCartridges, mountCartridge, switchCartridge, unmountCartridge } from '../api';
|
||||
import type { CartridgeManifest, MountState } from '../types';
|
||||
import { errorMessage, formatNumber, shortHash } from '../lib/format';
|
||||
import { Panel, Quiet } from './ui';
|
||||
|
||||
export function CartridgePanel({ onChanged }: { onChanged: () => void }) {
|
||||
const [cartridges, setCartridges] = useState<CartridgeManifest[]>([]);
|
||||
const [mounts, setMounts] = useState<MountState | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [nextCartridges, nextMounts] = await Promise.all([listCartridges(), getMounts()]);
|
||||
setCartridges(nextCartridges);
|
||||
setMounts(nextMounts);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadInitial() {
|
||||
try {
|
||||
const [nextCartridges, nextMounts] = await Promise.all([listCartridges(), getMounts()]);
|
||||
if (!cancelled) {
|
||||
setCartridges(nextCartridges);
|
||||
setMounts(nextMounts);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadInitial();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function act(cartridgeId: string, verb: 'mount' | 'switch' | 'unmount') {
|
||||
setBusy(`${verb}-${cartridgeId}`);
|
||||
setError(null);
|
||||
try {
|
||||
if (verb === 'mount') {
|
||||
await mountCartridge(cartridgeId);
|
||||
} else if (verb === 'switch') {
|
||||
await switchCartridge(cartridgeId);
|
||||
} else {
|
||||
await unmountCartridge(cartridgeId);
|
||||
}
|
||||
setNotice(`${verb} ${cartridgeId}`);
|
||||
await load();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="Cartridges" icon={<Archive size={17} />}>
|
||||
<div className="metric-strip">
|
||||
<span>{mounts?.mounted.length ?? 0} mounted</span>
|
||||
<span>{mounts?.scoped ? 'search is scoped' : 'search sees the whole store'}</span>
|
||||
</div>
|
||||
<div className="adapter-list">
|
||||
{cartridges.map((cartridge) => {
|
||||
const mounted = mounts?.mounted.includes(cartridge.cartridge_id) ?? false;
|
||||
return (
|
||||
<article className="adapter-row" key={cartridge.cartridge_id}>
|
||||
<header>
|
||||
<span>{cartridge.name}</span>
|
||||
{mounted && <span className="proof-pill passed">mounted</span>}
|
||||
</header>
|
||||
<strong>{cartridge.cartridge_id}</strong>
|
||||
<div className="adapter-metrics">
|
||||
<small>{formatNumber(cartridge.atom_count)} atoms</small>
|
||||
<small>{formatNumber(cartridge.source_count)} sources</small>
|
||||
<small>{formatNumber(cartridge.token_equivalent)} token eq</small>
|
||||
<small>{cartridge.model_base}</small>
|
||||
<small title={cartridge.dna ?? undefined}>
|
||||
{cartridge.dna ? `dna ${shortHash(cartridge.dna)}` : 'dna pending'}
|
||||
</small>
|
||||
<small>
|
||||
{cartridge.kv_fingerprint
|
||||
? 'kv fingerprint pinned'
|
||||
: 'no kv fingerprint: mounting must re-check this machine'}
|
||||
</small>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="action-button secondary"
|
||||
onClick={() => act(cartridge.cartridge_id, 'mount')}
|
||||
disabled={busy !== null || mounted}
|
||||
>
|
||||
<Play size={15} />
|
||||
Mount
|
||||
</button>
|
||||
<button
|
||||
className="action-button secondary"
|
||||
onClick={() => act(cartridge.cartridge_id, 'switch')}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<SwitchCamera size={15} />
|
||||
Switch
|
||||
</button>
|
||||
<button
|
||||
className="action-button secondary"
|
||||
onClick={() => act(cartridge.cartridge_id, 'unmount')}
|
||||
disabled={busy !== null || !mounted}
|
||||
>
|
||||
<Square size={15} />
|
||||
Unmount
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{cartridges.length === 0 && <Quiet>No cartridge has been finalized yet.</Quiet>}
|
||||
</div>
|
||||
{notice && <div className="metric-strip"><span>{notice}</span></div>}
|
||||
{error && <div className="error-line">{error}</div>}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Braces, Send } from 'lucide-react';
|
||||
import { FormEvent, useState } from 'react';
|
||||
|
||||
import { askQuestion, atomImageUrl } from '../api';
|
||||
import type { AnswerResponse, RuntimeProfile } from '../types';
|
||||
import { errorMessage } from '../lib/format';
|
||||
import { Panel, Quiet } from './ui';
|
||||
|
||||
const PROFILES: Array<{ id: RuntimeProfile; label: string }> = [
|
||||
{ id: 'live_demo', label: 'Live Demo' },
|
||||
{ id: 'extreme_context', label: 'Extreme' },
|
||||
{ id: 'library_100m', label: '100M Library' },
|
||||
{ id: 'research_theater', label: 'Research' },
|
||||
];
|
||||
|
||||
export function CompilerPanel({ onChanged }: { onChanged: () => void }) {
|
||||
const [profile, setProfile] = useState<RuntimeProfile>('library_100m');
|
||||
const [question, setQuestion] = useState('');
|
||||
const [answer, setAnswer] = useState<AnswerResponse | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submitQuestion(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
setBusy('chat');
|
||||
setError(null);
|
||||
try {
|
||||
setAnswer(await askQuestion(question, profile));
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="Context Compiler" icon={<Braces size={17} />}>
|
||||
<div className="segments">
|
||||
{PROFILES.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={profile === item.id ? 'segment active' : 'segment'}
|
||||
onClick={() => setProfile(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form className="stack" onSubmit={submitQuestion}>
|
||||
<textarea value={question} onChange={(event) => setQuestion(event.target.value)} rows={5} />
|
||||
<button className="action-button" disabled={!question || busy === 'chat'}>
|
||||
<Send size={16} />
|
||||
Ask
|
||||
</button>
|
||||
</form>
|
||||
{answer && (
|
||||
<div className="answer-box">
|
||||
<p>{answer.answer}</p>
|
||||
<div className="metric-strip">
|
||||
<span>{answer.context.hits.length} hits</span>
|
||||
<span>{Math.round(answer.verifier.citation_accuracy * 100)}% citations</span>
|
||||
<span>{answer.runtime_backend}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="evidence-list">
|
||||
{(answer?.context.hits ?? []).slice(0, 6).map((hit) => (
|
||||
<article key={hit.atom.atom_id} className="evidence-row">
|
||||
<header>
|
||||
<span>{hit.atom.modality}</span>
|
||||
<strong>{hit.final_score.toFixed(3)}</strong>
|
||||
</header>
|
||||
{hit.atom.image_ref && (
|
||||
<img className="thumb" src={atomImageUrl(hit.atom.atom_id)} alt={hit.atom.atom_id} loading="lazy" />
|
||||
)}
|
||||
<p>{hit.atom.text.slice(0, 240)}</p>
|
||||
<small>
|
||||
{hit.atom.source} · {hit.atom.holder_id || 'no holder'}
|
||||
</small>
|
||||
</article>
|
||||
))}
|
||||
{!answer && <Quiet>Evidence appears after a compiled answer</Quiet>}
|
||||
</div>
|
||||
{error && <div className="error-line">{error}</div>}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { FileSearch, Upload } from 'lucide-react';
|
||||
import { FormEvent, useState } from 'react';
|
||||
|
||||
import { ingestPath, uploadFiles } from '../api';
|
||||
import type { IngestResponse } from '../types';
|
||||
import { errorMessage, formatNumber } from '../lib/format';
|
||||
import { Panel } from './ui';
|
||||
|
||||
export function IngestPanel({ onChanged }: { onChanged: () => void }) {
|
||||
const [path, setPath] = useState('');
|
||||
const [cartridgeName, setCartridgeName] = useState('ely-eye-cartridge');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [lastIngest, setLastIngest] = useState<IngestResponse | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run(label: string, work: () => Promise<IngestResponse>) {
|
||||
setBusy(label);
|
||||
setError(null);
|
||||
try {
|
||||
setLastIngest(await work());
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
function submitPath(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
void run('ingest-path', () => ingestPath(path, cartridgeName));
|
||||
}
|
||||
|
||||
function submitUpload(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
void run('upload', () => uploadFiles(files, cartridgeName));
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="Input Stream" icon={<Upload size={17} />}>
|
||||
<form className="stack" onSubmit={submitPath}>
|
||||
<label>
|
||||
Cartridge
|
||||
<input value={cartridgeName} onChange={(event) => setCartridgeName(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Local path
|
||||
<input value={path} onChange={(event) => setPath(event.target.value)} placeholder="/data/books" />
|
||||
</label>
|
||||
<button className="action-button" disabled={!path || busy === 'ingest-path'}>
|
||||
<FileSearch size={16} />
|
||||
Ingest Path
|
||||
</button>
|
||||
</form>
|
||||
<form className="stack" onSubmit={submitUpload}>
|
||||
<label>
|
||||
Files
|
||||
<input type="file" multiple onChange={(event) => setFiles(Array.from(event.target.files ?? []))} />
|
||||
</label>
|
||||
<button className="action-button secondary" disabled={files.length === 0 || busy === 'upload'}>
|
||||
<Upload size={16} />
|
||||
Upload Files
|
||||
</button>
|
||||
</form>
|
||||
{lastIngest && (
|
||||
<div className="metric-strip">
|
||||
<span>{lastIngest.source_count} sources</span>
|
||||
<span>{lastIngest.atom_count} atoms</span>
|
||||
<span>{formatNumber(lastIngest.token_equivalent)} tokens</span>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-line">{error}</div>}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { ChevronLeft, ChevronRight, Database, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Dispatch, FormEvent, ReactNode, SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import { atomImageUrl, forget, listAtoms, remember, search } from '../api';
|
||||
import type { AtomPage, EvidenceAtom, SearchResult } from '../types';
|
||||
import { errorMessage, formatAgo, formatNumber, formatStamp } from '../lib/format';
|
||||
import { Panel, Quiet } from './ui';
|
||||
|
||||
const MODALITIES = ['text', 'code', 'image', 'audio', 'pdf_page', 'pdf_region', 'video_frame', 'ui_screenshot'];
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export function MemoryPanel({ onChanged }: { onChanged: () => void }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [holder, setHolder] = useState('');
|
||||
const [scenario, setScenario] = useState('');
|
||||
const [modality, setModality] = useState('');
|
||||
const [statement, setStatement] = useState('');
|
||||
const [result, setResult] = useState<SearchResult | null>(null);
|
||||
const [page, setPage] = useState<AtomPage | null>(null);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadBrowse(nextCursor?: string) {
|
||||
try {
|
||||
const next = await listAtoms({ limit: PAGE_SIZE, cursor: nextCursor, modality, holder });
|
||||
setPage(next);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadFirstPage() {
|
||||
try {
|
||||
const next = await listAtoms({ limit: PAGE_SIZE, modality, holder });
|
||||
if (!cancelled) {
|
||||
setPage(next);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadFirstPage();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [modality, holder]);
|
||||
|
||||
function changeFilter(apply: Dispatch<SetStateAction<string>>, value: string) {
|
||||
apply(value);
|
||||
setHistory([]);
|
||||
setCursor(undefined);
|
||||
}
|
||||
|
||||
async function submitSearch(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
setBusy('search');
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
setResult(await search(query, { holder, scenario, modality }));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRemember(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!statement) {
|
||||
return;
|
||||
}
|
||||
setBusy('remember');
|
||||
setError(null);
|
||||
try {
|
||||
await remember(statement, scenario);
|
||||
setNotice(`kept: ${statement.slice(0, 60)}`);
|
||||
setStatement('');
|
||||
await loadBrowse();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForget(atom: EvidenceAtom) {
|
||||
setBusy(`forget-${atom.atom_id}`);
|
||||
setError(null);
|
||||
try {
|
||||
const report = await forget(atom.atom_id, 'forgotten from the console');
|
||||
setNotice(
|
||||
`forgotten: ${report.atoms_deleted} atom${report.atoms_deleted === 1 ? '' : 's'}, ` +
|
||||
`${report.edges_deleted} links, ${report.cache_entries_cleared} cache entries` +
|
||||
(report.vector_index_dropped ? ', dense index dropped' : ''),
|
||||
);
|
||||
setResult(null);
|
||||
await loadBrowse();
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (!page?.next_cursor) {
|
||||
return;
|
||||
}
|
||||
setHistory([...history, cursor ?? '']);
|
||||
setCursor(page.next_cursor);
|
||||
void loadBrowse(page.next_cursor);
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (history.length === 0) {
|
||||
return;
|
||||
}
|
||||
const previous = history[history.length - 1];
|
||||
setHistory(history.slice(0, -1));
|
||||
setCursor(previous || undefined);
|
||||
void loadBrowse(previous || undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="Memory" icon={<Database size={17} />}>
|
||||
<form className="stack" onSubmit={submitSearch}>
|
||||
<label>
|
||||
Ask what someone knows
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="where are the keys" />
|
||||
</label>
|
||||
<div className="filter-row">
|
||||
<label>
|
||||
Holder
|
||||
<input
|
||||
value={holder}
|
||||
onChange={(event) => changeFilter(setHolder, event.target.value)}
|
||||
placeholder="any"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Scenario
|
||||
<input value={scenario} onChange={(event) => setScenario(event.target.value)} placeholder="any" />
|
||||
</label>
|
||||
<label>
|
||||
Modality
|
||||
<select value={modality} onChange={(event) => changeFilter(setModality, event.target.value)}>
|
||||
<option value="">any</option>
|
||||
{MODALITIES.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button className="action-button" disabled={!query || busy === 'search'}>
|
||||
<Search size={16} />
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{result && (
|
||||
<div className="atom-list">
|
||||
{result.status === 'no_match' && <Quiet>Nothing in the store answers “{result.query}”.</Quiet>}
|
||||
{result.hits.map((hit) => (
|
||||
<AtomRow
|
||||
key={hit.atom.atom_id}
|
||||
atom={hit.atom}
|
||||
meta={`${hit.final_score.toFixed(3)} · sparse ${hit.sparse_score.toFixed(2)} · visual ${hit.visual_score.toFixed(2)}`}
|
||||
busy={busy === `forget-${hit.atom.atom_id}`}
|
||||
onForget={() => submitForget(hit.atom)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="stack" onSubmit={submitRemember}>
|
||||
<label>
|
||||
Tell it something
|
||||
<input value={statement} onChange={(event) => setStatement(event.target.value)} placeholder="the spare glasses are in the hall drawer" />
|
||||
</label>
|
||||
<button className="action-button secondary" disabled={!statement || busy === 'remember'}>
|
||||
<Plus size={16} />
|
||||
Remember
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="atom-list">
|
||||
{(page?.atoms ?? []).map((atom) => (
|
||||
<AtomRow
|
||||
key={atom.atom_id}
|
||||
atom={atom}
|
||||
meta={formatAgo(atom.event_time ?? atom.ingested_at)}
|
||||
busy={busy === `forget-${atom.atom_id}`}
|
||||
onForget={() => submitForget(atom)}
|
||||
/>
|
||||
))}
|
||||
{page && page.atoms.length === 0 && <Quiet>The store is empty.</Quiet>}
|
||||
</div>
|
||||
|
||||
<div className="pager">
|
||||
<button className="icon-button" onClick={previousPage} disabled={history.length === 0} title="Previous page">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<small>
|
||||
{page ? `${page.atoms.length} of ${formatNumber(page.total)} atoms` : 'loading'}
|
||||
</small>
|
||||
<button className="icon-button" onClick={nextPage} disabled={!page?.next_cursor} title="Next page">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="metric-strip"><span>{notice}</span></div>}
|
||||
{error && <div className="error-line">{error}</div>}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function AtomRow({
|
||||
atom,
|
||||
meta,
|
||||
busy,
|
||||
onForget,
|
||||
}: {
|
||||
atom: EvidenceAtom;
|
||||
meta: string;
|
||||
busy: boolean;
|
||||
onForget: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<article className="atom-row">
|
||||
{atom.image_ref && <img className="thumb" src={atomImageUrl(atom.atom_id)} alt={atom.atom_id} loading="lazy" />}
|
||||
<div className="atom-body">
|
||||
<header>
|
||||
<span>{atom.modality}</span>
|
||||
<strong>{meta}</strong>
|
||||
</header>
|
||||
<p>{atom.text.slice(0, 200) || <em>no text on this atom</em>}</p>
|
||||
<small>
|
||||
{atom.holder_id || 'no holder'} · {atom.origin} · {atom.scenario || 'no scenario'} ·{' '}
|
||||
{formatStamp(atom.event_time ?? atom.ingested_at)} · parser {atom.trust.parser.toFixed(2)}
|
||||
</small>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onForget} disabled={busy} title="Forget this memory">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { GitBranch, Play, ShieldCheck } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { getLatestProofSuite, runHashHop, runProofSuite } from '../api';
|
||||
import type { HashHopProof, ProofSuiteReport } from '../types';
|
||||
import { errorMessage, formatNumber, shortHash } from '../lib/format';
|
||||
import { Panel, Quiet } from './ui';
|
||||
|
||||
export function ProofPanel() {
|
||||
const [proofSuite, setProofSuite] = useState<ProofSuiteReport | null>(null);
|
||||
const [hashHop, setHashHop] = useState<HashHopProof | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadLatest() {
|
||||
try {
|
||||
const latest = await getLatestProofSuite().catch(() => null);
|
||||
if (!cancelled) {
|
||||
setProofSuite(latest);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadLatest();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const passed = proofSuite?.checks.filter((check) => check.status === 'passed').length ?? 0;
|
||||
|
||||
async function submitSuite() {
|
||||
setBusy('proof-suite');
|
||||
setError(null);
|
||||
try {
|
||||
setProofSuite(await runProofSuite());
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitHashHop(kind: 'text' | 'visual') {
|
||||
setBusy(`hashhop-${kind}`);
|
||||
setError(null);
|
||||
try {
|
||||
setHashHop(await runHashHop(kind, 2, kind === 'visual' ? 262144 : 1010000));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="PRD Proof Suite" icon={<ShieldCheck size={17} />}>
|
||||
<div className="proof-suite-header">
|
||||
<button className="action-button secondary" onClick={submitSuite} disabled={busy === 'proof-suite'}>
|
||||
<Play size={16} />
|
||||
Run Suite
|
||||
</button>
|
||||
<div className={proofSuite?.status === 'passed' ? 'proof-pill passed' : 'proof-pill'}>
|
||||
{proofSuite ? `${passed}/${proofSuite.checks.length} passed` : 'pending'}
|
||||
</div>
|
||||
</div>
|
||||
{proofSuite && (
|
||||
<div className="proof-list">
|
||||
{proofSuite.checks.map((check) => (
|
||||
<article className="proof-row" key={check.name}>
|
||||
<header>
|
||||
<strong>{check.name}</strong>
|
||||
<span className={check.status === 'passed' ? 'proof-status passed' : 'proof-status'}>{check.status}</span>
|
||||
</header>
|
||||
<p>{check.detail}</p>
|
||||
<small>{check.evidence[0]}</small>
|
||||
</article>
|
||||
))}
|
||||
<div className="proof-box">
|
||||
<strong>{proofSuite.proof_id}</strong>
|
||||
<span>{proofSuite.status}</span>
|
||||
<small>{proofSuite.cartridge_id}</small>
|
||||
<small>{proofSuite.dna_after ?? proofSuite.dna_before}</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!proofSuite && <Quiet>Proof suite has not run yet</Quiet>}
|
||||
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="action-button secondary"
|
||||
onClick={() => submitHashHop('visual')}
|
||||
disabled={busy === 'hashhop-visual'}
|
||||
>
|
||||
<GitBranch size={15} />
|
||||
Visual 2-hop
|
||||
</button>
|
||||
<button
|
||||
className="action-button secondary"
|
||||
onClick={() => submitHashHop('text')}
|
||||
disabled={busy === 'hashhop-text'}
|
||||
>
|
||||
<GitBranch size={15} />
|
||||
Text 2-hop
|
||||
</button>
|
||||
</div>
|
||||
{hashHop && (
|
||||
<div className="proof-box">
|
||||
<strong>{shortHash(hashHop.proof_id)}</strong>
|
||||
<span>{hashHop.kind}</span>
|
||||
<span>{hashHop.hops} hops</span>
|
||||
<span>{formatNumber(hashHop.token_equivalent)} token eq</span>
|
||||
<small>{hashHop.expected_target_id}</small>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-line">{error}</div>}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
import type { SenseStatus } from '../types';
|
||||
import { formatNumber } from '../lib/format';
|
||||
import { Panel, Quiet } from './ui';
|
||||
|
||||
export function SensesPanel({ senses }: { senses: SenseStatus | null }) {
|
||||
return (
|
||||
<Panel title="Senses" icon={<Eye size={17} />}>
|
||||
<div className="layer-list">
|
||||
{senses && (
|
||||
<>
|
||||
<SenseRow
|
||||
label="dense vectors"
|
||||
state={senses.dense}
|
||||
detail={`${formatNumber(senses.dense_covered)} of ${formatNumber(senses.store_atoms)} atoms indexed`}
|
||||
/>
|
||||
<SenseRow
|
||||
label="picture embeddings"
|
||||
state={senses.visual}
|
||||
detail={
|
||||
`${formatNumber(senses.picture_atoms)} pictures in the store` +
|
||||
(senses.picture_bytes_missing > 0
|
||||
? `, ${formatNumber(senses.picture_bytes_missing)} with no bytes behind them`
|
||||
: '')
|
||||
}
|
||||
/>
|
||||
<SenseRow
|
||||
label="speech"
|
||||
state={senses.speech}
|
||||
detail={`${formatNumber(senses.audio_atoms)} audio atoms`}
|
||||
/>
|
||||
<SenseRow label="captioning" state={senses.captioning} detail="turns a wordless picture into text" />
|
||||
</>
|
||||
)}
|
||||
{!senses && <Quiet>Waiting for status.</Quiet>}
|
||||
</div>
|
||||
{senses && (
|
||||
<div className="adapter-metrics">
|
||||
<small title={senses.clip_path ?? undefined}>{senses.clip_path ? `clip ${senses.clip_path}` : 'no CLIP snapshot configured'}</small>
|
||||
<small title={senses.asr_path ?? undefined}>{senses.asr_path ? `asr ${senses.asr_path}` : 'no speech snapshot configured'}</small>
|
||||
<small title={senses.vlm_path ?? undefined}>{senses.vlm_path ? `vlm ${senses.vlm_path}` : 'no captioning model configured'}</small>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function SenseRow({ label, state, detail }: { label: string; state: string; detail: string }) {
|
||||
return (
|
||||
<div className="layer-row">
|
||||
<span>{label}</span>
|
||||
<strong>{state}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export function Panel({ title, icon, children }: { title: string; icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<header className="panel-header">
|
||||
<span className="panel-icon">{icon}</span>
|
||||
<h2>{title}</h2>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Kpi({ icon, label, value, detail }: { icon: ReactNode; label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<section className="kpi">
|
||||
<span className="panel-icon">{icon}</span>
|
||||
<div>
|
||||
<p>{label}</p>
|
||||
<strong>{value}</strong>
|
||||
<small>{detail}</small>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Quiet({ children }: { children: ReactNode }) {
|
||||
return <div className="quiet-line">{children}</div>;
|
||||
}
|
||||
|
||||
export function Pill({ state, children }: { state?: 'passed' | 'failed' | string; children: ReactNode }) {
|
||||
const tone = state === 'passed' || state === 'ready' || state === 'loaded' || state === 'current' ? ' passed' : '';
|
||||
return <span className={`proof-pill${tone}`}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { AdapterStatus, SystemStatus } from '../types';
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value);
|
||||
}
|
||||
|
||||
export function formatBytes(value: number): string {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
if (value < 1024 * 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KiB`;
|
||||
}
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
export function formatDuration(value: number | null): string {
|
||||
if (typeof value !== 'number') {
|
||||
return 'time pending';
|
||||
}
|
||||
if (value < 60) {
|
||||
return `${value.toFixed(1)}s`;
|
||||
}
|
||||
return `${Math.round(value / 60)}m`;
|
||||
}
|
||||
|
||||
export function shortHash(value: string): string {
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
export function formatOptimizer(adapter: AdapterStatus): string {
|
||||
if (adapter.optimizer === 'paged_adamw_8bit') {
|
||||
return 'paged 8-bit AdamW';
|
||||
}
|
||||
return adapter.optimizer_family ?? adapter.optimizer ?? 'optimizer pending';
|
||||
}
|
||||
|
||||
export function formatFramework(value: string): string {
|
||||
if (value === 'unsloth_patched_transformers_peft') {
|
||||
return 'Unsloth patch + PEFT';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatCheckpointing(adapter: AdapterStatus): string {
|
||||
if (adapter.gradient_checkpointing_mode === 'unsloth') {
|
||||
return 'ckpt unsloth';
|
||||
}
|
||||
return adapter.gradient_checkpointing ? 'ckpt on' : 'ckpt pending';
|
||||
}
|
||||
|
||||
export function formatRuntimeDetail(status: SystemStatus | null): string {
|
||||
const model = status?.runtime.model_id ?? 'Qwen/Qwen3.5-9B';
|
||||
const adapter = status?.runtime.adapter_kind?.replaceAll('_', '-');
|
||||
return adapter ? `${model} · ${adapter}` : model;
|
||||
}
|
||||
|
||||
export function formatVram(status: SystemStatus | null): string {
|
||||
const total = status?.runtime.vram_total_mb;
|
||||
const used = status?.runtime.vram_used_mb;
|
||||
if (typeof total === 'number' && typeof used === 'number') {
|
||||
return `${used} / ${total} MiB`;
|
||||
}
|
||||
return status?.runtime.cuda ?? 'CUDA';
|
||||
}
|
||||
|
||||
export function formatStamp(iso: string | null): string {
|
||||
if (!iso) {
|
||||
return 'no time given';
|
||||
}
|
||||
return `${iso.slice(0, 10)} ${iso.slice(11, 16)}Z`;
|
||||
}
|
||||
|
||||
export function formatAgo(iso: string | null): string {
|
||||
if (!iso) {
|
||||
return 'undated';
|
||||
}
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) {
|
||||
return 'just now';
|
||||
}
|
||||
const units: Array<[number, string]> = [[3600, 'h'], [86400, 'd'], [2592000, 'mo'], [31536000, 'y']];
|
||||
for (const [size, name] of units.slice().reverse()) {
|
||||
if (seconds >= size) {
|
||||
return `${Math.floor(seconds / size)}${name} ago`;
|
||||
}
|
||||
}
|
||||
return `${Math.floor(seconds / 60)}m ago`;
|
||||
}
|
||||
@@ -442,3 +442,51 @@ meter {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.atom-row {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.atom-body {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.atom-body header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.atom-body p,
|
||||
.atom-body small {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border: 1px solid #d7ded2;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
+136
-9
@@ -32,11 +32,27 @@ export interface MemoryStatus {
|
||||
layers: CacheLayerStatus[];
|
||||
}
|
||||
|
||||
export interface SenseStatus {
|
||||
dense: 'off' | 'missing' | 'behind' | 'current';
|
||||
dense_covered: number;
|
||||
store_atoms: number;
|
||||
picture_atoms: number;
|
||||
picture_bytes_missing: number;
|
||||
visual: 'absent' | 'stale' | 'ready';
|
||||
audio_atoms: number;
|
||||
speech: 'absent' | 'missing' | 'ready';
|
||||
captioning: 'absent' | 'missing' | 'ready';
|
||||
clip_path: string | null;
|
||||
asr_path: string | null;
|
||||
vlm_path: string | null;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
app: string;
|
||||
version: string;
|
||||
runtime: RuntimeStatus;
|
||||
memory: MemoryStatus;
|
||||
senses: SenseStatus;
|
||||
profiles: Record<string, Record<string, unknown>>;
|
||||
data_home: string;
|
||||
}
|
||||
@@ -108,24 +124,65 @@ export interface LayoutBox {
|
||||
frame_second?: number | null;
|
||||
}
|
||||
|
||||
export interface TrustScores {
|
||||
parser: number;
|
||||
ocr: number;
|
||||
model: number;
|
||||
}
|
||||
|
||||
export interface EvidenceAtom {
|
||||
atom_id: string;
|
||||
modality: string;
|
||||
source_id: string;
|
||||
source: string;
|
||||
text: string;
|
||||
image_ref: string | null;
|
||||
layout: LayoutBox | null;
|
||||
token_equivalent: number;
|
||||
ingested_at: string;
|
||||
holder_id: string;
|
||||
origin: string;
|
||||
scenario: string;
|
||||
event_time: string | null;
|
||||
event_time_precision: string;
|
||||
valid_from: string | null;
|
||||
valid_to: string | null;
|
||||
trust: TrustScores;
|
||||
}
|
||||
|
||||
export interface AtomPage {
|
||||
atoms: EvidenceAtom[];
|
||||
total: number;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
holder?: string | null;
|
||||
as_holder?: string | null;
|
||||
scenario?: string | null;
|
||||
source_id?: string | null;
|
||||
modality?: string | null;
|
||||
since?: string | null;
|
||||
until?: string | null;
|
||||
min_trust?: number | null;
|
||||
}
|
||||
|
||||
export interface RetrievalHit {
|
||||
atom: EvidenceAtom;
|
||||
sparse_score: number;
|
||||
dense_score: number;
|
||||
visual_score: number;
|
||||
graph_score: number;
|
||||
final_score: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
query: string;
|
||||
status: 'hits' | 'no_match';
|
||||
hits: RetrievalHit[];
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface AnswerResponse {
|
||||
answer: string;
|
||||
citations: string[];
|
||||
@@ -176,13 +233,83 @@ export interface ProofSuiteReport {
|
||||
dna_after: string | null;
|
||||
}
|
||||
|
||||
export interface MemoryMap {
|
||||
atoms: EvidenceAtom[];
|
||||
layers: CacheLayerStatus[];
|
||||
cartridges: Array<{
|
||||
cartridge_id: string;
|
||||
name: string;
|
||||
token_equivalent: number;
|
||||
dna: string | null;
|
||||
}>;
|
||||
export interface CartridgeManifest {
|
||||
cartridge_id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
model_base: string;
|
||||
source_count: number;
|
||||
atom_count: number;
|
||||
token_equivalent: number;
|
||||
artifacts: Record<string, string>;
|
||||
kv_fingerprint: Record<string, unknown> | null;
|
||||
dna: string | null;
|
||||
}
|
||||
|
||||
export interface MountState {
|
||||
mounted: string[];
|
||||
scoped: boolean;
|
||||
}
|
||||
|
||||
export interface AdapterChoice {
|
||||
kind: string;
|
||||
state: 'loaded' | 'available' | 'deferred' | 'missing';
|
||||
reason: string;
|
||||
path: string | null;
|
||||
adapter_id: string | null;
|
||||
base_model: string | null;
|
||||
origin: 'library' | 'cartridge' | null;
|
||||
cartridge_id: string | null;
|
||||
shadowed: boolean;
|
||||
}
|
||||
|
||||
export interface AdapterLoadReport {
|
||||
model_id: string;
|
||||
mounted: string[];
|
||||
adapters: AdapterChoice[];
|
||||
}
|
||||
|
||||
export interface RuntimeLaunchProfile {
|
||||
name: string;
|
||||
runtime: string;
|
||||
role: string;
|
||||
context_tokens: number;
|
||||
script: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
required_features: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeProfileValidation {
|
||||
name: string;
|
||||
runtime: string;
|
||||
status: 'ready' | 'missing';
|
||||
script_exists: boolean;
|
||||
package: string | null;
|
||||
package_version: string | null;
|
||||
executable: string | null;
|
||||
gpu_available: boolean;
|
||||
probe_command: string[];
|
||||
probe_status: 'passed' | 'failed' | 'skipped';
|
||||
probe_exit_code: number | null;
|
||||
probe_output: string | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface RuntimeProfileReport {
|
||||
status: 'ready' | 'incomplete';
|
||||
created_at: string;
|
||||
profiles: RuntimeLaunchProfile[];
|
||||
validations: RuntimeProfileValidation[];
|
||||
}
|
||||
|
||||
export interface ForgetReport {
|
||||
source_id: string;
|
||||
atom_ids: string[];
|
||||
atoms_deleted: number;
|
||||
edges_deleted: number;
|
||||
cache_entries_cleared: number;
|
||||
vector_index_dropped: boolean;
|
||||
affected_cartridges: Array<Record<string, unknown>>;
|
||||
reason: string;
|
||||
}
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# The console is proven by driving it: a fresh home, the built interface served by the
|
||||
# API itself, and a browser that searches, pages, forgets, mounts and reads adapter states.
|
||||
# ELY_EYE_UI_SMOKE_KEEP=1 leaves the temporary home in place and prints it.
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
python="${PYTHON:-$repo/.venv/bin/python}"
|
||||
server_bin="$(dirname "$python")/ely-eye"
|
||||
if [[ ! -x "$server_bin" ]]; then
|
||||
echo "$server_bin is not installed; run scripts/install.sh first" >&2
|
||||
exit 1
|
||||
fi
|
||||
port="${ELY_EYE_UI_SMOKE_PORT:-8791}"
|
||||
api="http://127.0.0.1:${port}"
|
||||
home="$(mktemp -d "${TMPDIR:-/tmp}/ely-eye-ui-smoke.XXXXXX")"
|
||||
server=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$server" ]] && kill -0 "$server" 2>/dev/null; then
|
||||
kill "$server" 2>/dev/null || true
|
||||
wait "$server" 2>/dev/null || true
|
||||
fi
|
||||
if [[ "${ELY_EYE_UI_SMOKE_KEEP:-}" == "1" ]]; then
|
||||
echo "kept: $home"
|
||||
else
|
||||
rm -rf "$home"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ! -f "$repo/frontend/dist/index.html" ]]; then
|
||||
(cd "$repo/frontend" && npm run build)
|
||||
fi
|
||||
|
||||
export ELY_EYE_HOME="$home"
|
||||
export ELY_EYE_DEFAULT_HOLDER=wife
|
||||
export ELY_EYE_UI_DIR="$repo/frontend/dist"
|
||||
|
||||
"$server_bin" serve --host 127.0.0.1 --port "$port" >"$home/serve.log" 2>&1 &
|
||||
server=$!
|
||||
|
||||
ready=""
|
||||
for _ in $(seq 1 120); do
|
||||
if curl -sf "$api/api/status" >/dev/null 2>&1; then
|
||||
ready=yes
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$server" 2>/dev/null; then
|
||||
echo "the API stopped before it was ready:" >&2
|
||||
cat "$home/serve.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ -z "$ready" ]]; then
|
||||
echo "the API never answered on $api:" >&2
|
||||
cat "$home/serve.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The interface is served by the same process, so the browser talks to one origin.
|
||||
if ! curl -sf "$api/" >/dev/null 2>&1; then
|
||||
echo "the API is up but does not serve the built interface at /; is frontend/dist real?" >&2
|
||||
cat "$home/serve.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
(cd "$repo/frontend" && ELY_EYE_API="$api" ELY_EYE_UI_URL="$api/" ELY_EYE_UI_SMOKE_DIR="$home" node scripts/verify-ui.mjs)
|
||||
|
||||
echo "PASS: the console searches, pages, forgets, mounts and reports adapter states"
|
||||
Reference in new issue
Block a user