feat: add embedded svelte frontend

This commit is contained in:
Jack O'Neill committed 2026-04-05 09:40:38 +02:00
1 parent f0e242293f
commit 6c6f4022a0
22 files changed
+2757 -145

No files matched your search

+2
View File
@@ -31,3 +31,5 @@ cmd/amcs-server/__debug_*
bin/
.cache/
OB1/
ui/node_modules/
ui/.svelte-kit/
+11
View File
@@ -1,3 +1,13 @@
FROM node:22-bookworm AS ui-builder
WORKDIR /src/ui
COPY ui/package.json ui/package-lock.json ./
RUN npm ci
COPY ui/ ./
RUN npm run build
FROM golang:1.26.1-bookworm AS builder
WORKDIR /src
@@ -6,6 +16,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=ui-builder /src/internal/app/ui/dist ./internal/app/ui/dist
RUN set -eu; \
VERSION_TAG="$(git describe --tags --exact-match 2>/dev/null || echo dev)"; \
+17 -3
View File
@@ -3,6 +3,8 @@ GO_CACHE_DIR := $(CURDIR)/.cache/go-build
SERVER_BIN := $(BIN_DIR)/amcs-server
CMD_SERVER := ./cmd/amcs-server
BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo
UI_DIR := $(CURDIR)/ui
UI_DIST_DIR := $(CURDIR)/internal/app/ui/dist
PATCH_INCREMENT ?= 1
VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev)
COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
@@ -13,15 +15,27 @@ LDFLAGS := -s -w \
-X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \
-X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE)
.PHONY: all build clean migrate release-version test
.PHONY: all build clean migrate release-version test ui-install ui-build ui-dev ui-check
all: build
build:
build: ui-build
@mkdir -p $(BIN_DIR)
go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER)
test:
ui-install:
npm --prefix $(UI_DIR) ci
ui-build: ui-install
npm --prefix $(UI_DIR) run build
ui-dev: ui-install
npm --prefix $(UI_DIR) run dev
ui-check: ui-install
npm --prefix $(UI_DIR) run check
test: ui-check
@mkdir -p $(GO_CACHE_DIR)
GOCACHE=$(GO_CACHE_DIR) go test ./...
+27 -1
View File
@@ -531,7 +531,33 @@ Run the SQL migrations against a local database with:
`DATABASE_URL=postgres://... make migrate`
LLM integration instructions are served at `/llm`.
### Backend + embedded UI build
The web UI now lives in the top-level `ui/` module and is embedded into the Go binary at build time with `go:embed`.
- `make build` — builds the Svelte/Tailwind frontend, then compiles the Go server
- `make test` — runs `svelte-check` for the frontend and `go test ./...` for the backend
- `make ui-build` — builds only the frontend into `internal/app/ui/dist`
- `make ui-dev` — starts the Vite dev server with hot reload on `http://localhost:5173`
### Local UI workflow
For the normal production-style local flow:
1. Start the backend: `./scripts/run-local.sh configs/dev.yaml`
2. Open `http://localhost:8080`
For frontend iteration with hot reload and no Go rebuilds:
1. Start the backend once: `go run ./cmd/amcs-server --config configs/dev.yaml`
2. In another shell start the UI dev server: `make ui-dev`
3. Open `http://localhost:5173`
The Vite dev server proxies backend routes such as `/api/status`, `/llm`, `/healthz`, `/readyz`, `/files`, `/mcp`, and the OAuth endpoints back to the Go server on `http://127.0.0.1:8080` by default. Override that target with `AMCS_UI_BACKEND` if needed.
The root page (`/`) is now the Svelte frontend. It preserves the existing landing-page content and status information by fetching data from `GET /api/status`.
LLM integration instructions are still served at `/llm`.
## Containers
+1
View File
@@ -212,6 +212,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
mux.HandleFunc("/images/project.jpg", serveHomeImage)
mux.HandleFunc("/images/icon.png", serveIcon)
mux.HandleFunc("/llm", serveLLMInstructions)
mux.HandleFunc("/api/status", statusAPIHandler(info, accessTracker, oauthEnabled))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
+97 -128
View File
@@ -1,9 +1,11 @@
package app
import (
"fmt"
"html"
"bytes"
"encoding/json"
"io/fs"
"net/http"
"path"
"strings"
"time"
@@ -13,131 +15,33 @@ import (
const connectedWindow = 10 * time.Minute
type statusPageData struct {
Version string
BuildDate string
Commit string
ConnectedCount int
TotalKnown int
Entries []auth.AccessSnapshot
OAuthEnabled bool
type statusAPIResponse struct {
Title string `json:"title"`
Description string `json:"description"`
Version string `json:"version"`
BuildDate string `json:"build_date"`
Commit string `json:"commit"`
ConnectedCount int `json:"connected_count"`
TotalKnown int `json:"total_known"`
ConnectedWindow string `json:"connected_window"`
Entries []auth.AccessSnapshot `json:"entries"`
OAuthEnabled bool `json:"oauth_enabled"`
}
func renderHomePage(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) string {
func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
entries := tracker.Snapshot()
data := statusPageData{
Version: fallback(info.Version, "dev"),
BuildDate: fallback(info.BuildDate, "unknown"),
Commit: fallback(info.Commit, "unknown"),
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
TotalKnown: len(entries),
Entries: entries,
OAuthEnabled: oauthEnabled,
return statusAPIResponse{
Title: "Avelon Memory Crystal Server (AMCS)",
Description: "AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.",
Version: fallback(info.Version, "dev"),
BuildDate: fallback(info.BuildDate, "unknown"),
Commit: fallback(info.Commit, "unknown"),
ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
TotalKnown: len(entries),
ConnectedWindow: "last 10 minutes",
Entries: entries,
OAuthEnabled: oauthEnabled,
}
var b strings.Builder
b.WriteString(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AMCS</title>
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f7fb; color: #172033; }
main { max-width: 980px; margin: 48px auto; background: #fff; border-radius: 12px; box-shadow: 0 10px 28px rgba(23, 32, 51, 0.12); overflow: hidden; }
.content { padding: 28px; }
h1, h2 { margin: 0 0 12px 0; }
p { margin: 0; line-height: 1.5; color: #334155; }
.actions { margin-top: 18px; display: flex; flex-wrap: wrap; gap: 10px; }
.link { display: inline-block; padding: 10px 14px; border-radius: 8px; background: #172033; color: #fff; text-decoration: none; font-weight: 600; }
.link:hover { background: #0f172a; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-top: 24px; }
.card { background: #eef2ff; border-radius: 10px; padding: 16px; }
.label { display: block; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; color: #475569; }
.value { display: block; margin-top: 6px; font-size: 1.4rem; font-weight: 700; color: #0f172a; }
.meta { margin-top: 28px; color: #475569; font-size: 0.95rem; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid #e2e8f0; vertical-align: top; }
th { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; color: #475569; }
.empty { margin-top: 16px; color: #64748b; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
img { display: block; width: 100%; height: auto; }
</style>
</head>
<body>
<main>
<img src="/images/project.jpg" alt="Avelon Memory Crystal project image">
<div class="content">
<h1>Avelon Memory Crystal Server (AMCS)</h1>
<p>AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.</p>
<div class="actions">
<a class="link" href="/llm">LLM Instructions</a>
<a class="link" href="/healthz">Health Check</a>
<a class="link" href="/readyz">Readiness Check</a>`)
if data.OAuthEnabled {
b.WriteString(`
<a class="link" href="/oauth-authorization-server">OAuth Authorization Server</a>`)
}
b.WriteString(`
</div>
<div class="stats">
<div class="card">
<span class="label">Connected users</span>
<span class="value">` + fmt.Sprintf("%d", data.ConnectedCount) + `</span>
</div>
<div class="card">
<span class="label">Known principals</span>
<span class="value">` + fmt.Sprintf("%d", data.TotalKnown) + `</span>
</div>
<div class="card">
<span class="label">Version</span>
<span class="value">` + html.EscapeString(data.Version) + `</span>
</div>
</div>
<div class="meta">
<strong>Build date:</strong> ` + html.EscapeString(data.BuildDate) + ` &nbsp;•&nbsp;
<strong>Commit:</strong> <code>` + html.EscapeString(data.Commit) + `</code> &nbsp;•&nbsp;
<strong>Connected window:</strong> last 10 minutes
</div>
<h2 style="margin-top: 28px;">Recent access</h2>`)
if len(data.Entries) == 0 {
b.WriteString(`
<p class="empty">No authenticated access recorded yet.</p>`)
} else {
b.WriteString(`
<table>
<thead>
<tr>
<th>Principal</th>
<th>Last accessed</th>
<th>Last path</th>
<th>Requests</th>
</tr>
</thead>
<tbody>`)
for _, entry := range data.Entries {
b.WriteString(`
<tr>
<td><code>` + html.EscapeString(entry.KeyID) + `</code></td>
<td>` + html.EscapeString(entry.LastAccessedAt.UTC().Format(time.RFC3339)) + `</td>
<td>` + html.EscapeString(entry.LastPath) + `</td>
<td>` + fmt.Sprintf("%d", entry.RequestCount) + `</td>
</tr>`)
}
b.WriteString(`
</tbody>
</table>`)
}
b.WriteString(`
</div>
</main>
</body>
</html>`)
return b.String()
}
func fallback(value, defaultValue string) string {
@@ -147,25 +51,90 @@ func fallback(value, defaultValue string) string {
return value
}
func homeHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc {
func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
if r.URL.Path != "/api/status" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write([]byte(renderHomePage(info, tracker, oauthEnabled, time.Now())))
_ = json.NewEncoder(w).Encode(statusSnapshot(info, tracker, oauthEnabled, time.Now()))
}
}
func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
requestPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if requestPath == "." {
requestPath = ""
}
if requestPath != "" {
if serveUIAsset(w, r, requestPath) {
return
}
http.NotFound(w, r)
return
}
serveUIIndex(w, r)
}
}
func serveUIAsset(w http.ResponseWriter, r *http.Request, name string) bool {
if uiDistFS == nil {
return false
}
if strings.Contains(name, "..") {
return false
}
file, err := uiDistFS.Open(name)
if err != nil {
return false
}
defer file.Close()
info, err := file.Stat()
if err != nil || info.IsDir() {
return false
}
data, err := fs.ReadFile(uiDistFS, name)
if err != nil {
return false
}
http.ServeContent(w, r, info.Name(), info.ModTime(), bytes.NewReader(data))
return true
}
func serveUIIndex(w http.ResponseWriter, r *http.Request) {
if indexHTML == nil {
http.Error(w, "ui assets not built", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(indexHTML)
}
+61 -12
View File
@@ -1,6 +1,7 @@
package app
import (
"encoding/json"
"io"
"log/slog"
"net/http"
@@ -14,29 +15,62 @@ import (
"git.warky.dev/wdevs/amcs/internal/config"
)
func TestRenderHomePageHidesOAuthLinkWhenDisabled(t *testing.T) {
func TestStatusSnapshotHidesOAuthLinkWhenDisabled(t *testing.T) {
tracker := auth.NewAccessTracker()
page := renderHomePage(buildinfo.Info{Version: "v1.2.3", BuildDate: "2026-04-04", Commit: "abc123"}, tracker, false, time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC))
snapshot := statusSnapshot(buildinfo.Info{Version: "v1.2.3", BuildDate: "2026-04-04", Commit: "abc123"}, tracker, false, time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC))
if strings.Contains(page, "/oauth-authorization-server") {
t.Fatal("page unexpectedly contains OAuth link")
if snapshot.OAuthEnabled {
t.Fatal("OAuthEnabled = true, want false")
}
if !strings.Contains(page, "Connected users") {
t.Fatal("page missing Connected users stat")
if snapshot.ConnectedCount != 0 {
t.Fatalf("ConnectedCount = %d, want 0", snapshot.ConnectedCount)
}
if snapshot.Title == "" {
t.Fatal("Title = empty, want non-empty")
}
}
func TestRenderHomePageShowsTrackedAccess(t *testing.T) {
func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
tracker := auth.NewAccessTracker()
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
tracker.Record("client-a", "/files", "127.0.0.1:1234", "tester", now)
page := renderHomePage(buildinfo.Info{Version: "v1.2.3"}, tracker, true, now)
snapshot := statusSnapshot(buildinfo.Info{Version: "v1.2.3"}, tracker, true, now)
for _, needle := range []string{"client-a", "/files", "1</span>", "/oauth-authorization-server"} {
if !strings.Contains(page, needle) {
t.Fatalf("page missing %q", needle)
}
if !snapshot.OAuthEnabled {
t.Fatal("OAuthEnabled = false, want true")
}
if snapshot.ConnectedCount != 1 {
t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount)
}
if len(snapshot.Entries) != 1 {
t.Fatalf("len(Entries) = %d, want 1", len(snapshot.Entries))
}
if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" {
t.Fatalf("entry = %+v, want keyID client-a and path /files", snapshot.Entries[0])
}
}
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
req := httptest.NewRequest(http.MethodGet, "/api/status", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("content-type = %q, want application/json", got)
}
var payload statusAPIResponse
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if payload.Version != "v1" {
t.Fatalf("version = %q, want %q", payload.Version, "v1")
}
}
@@ -55,6 +89,21 @@ func TestHomeHandlerAllowsHead(t *testing.T) {
}
}
func TestHomeHandlerServesIndex(t *testing.T) {
handler := homeHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), false)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !strings.Contains(rec.Body.String(), "<div id=\"app\"></div>") {
t.Fatalf("body = %q, want embedded UI index", rec.Body.String())
}
}
func TestMiddlewareRecordsAuthenticatedAccess(t *testing.T) {
keyring, err := auth.NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}})
if err != nil {
+1
View File
@@ -0,0 +1 @@
/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-emerald-400:oklch(76.5% .177 163.223);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-400:oklch(71.2% .194 13.428);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-900:oklch(20.8% .042 265.755);--color-slate-950:oklch(12.9% .042 264.695);--color-white:#fff;--spacing:.25rem;--container-3xl:48rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height: 1.2 ;--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:coLine truncated
+1
View File
@@ -0,0 +1 @@
var Br=Object.defineProperty;var bn=e=>{throw TypeError(e)};var Ur=(e,t,n)=>t in e?Br(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var be=(e,t,n)=>Ur(e,typeof t!="symbol"?t+"":t,n),Bt=(e,t,n)=>t.has(e)||bn("Cannot "+n);var c=(e,t,n)=>(Bt(e,t,"read from private field"),n?n.call(e):t.get(e)),M=(e,t,n)=>t.has(e)?bn("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),De=(e,t,n,r)=>(Bt(e,t,"write to private field"),r?r.call(e,n):t.set(e,n),n),U=(e,t,n)=>(Bt(e,t,"access private method"),n);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))r(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const a of i.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&r(a)}).observe(document,{childList:!0,subtree:!0});function n(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(s){if(s.ep)return;s.ep=!0;const i=n(s);fetch(s.href,i)}})();const Vr="5";var In;typeof window<"u"&&((In=window.__svelte??(window.__svelte={})).v??(In.v=new Set)).add(Vr);let pt=!1,Yr=!1;function Kr(){pt=!0}Kr();const Gr=1,Wr=2,Ln=4,$r=8,Xr=16,Zr=2,F=Symbol(),Pn="http://www.w3.org/1999/xhtml",Jr=!1;var jn=Array.isArray,Qr=Array.prototype.indexOf,$e=Array.prototype.includes,rn=Array.from,es=Object.defineProperty,Ut=Object.getOwnPropertyDescriptor,qn=Object.getOwnPropertyDescriptors,ts=Object.prototype,ns=Array.prototype,sn=Object.getPrototypeOf;const rs=()=>{};function ss(e){return e()}function Gt(e){for(var t=0;t<e.length;t++)e[t]()}function zn(){var e,t,n=new Promise((r,s)=>{e=r,t=s});return{promise:n,resolve:e,reject:t}}const I=2,Xe=4,qt=8,Hn=1<<24,we=16,se=32,Ze=64,is=128,X=512,N=1024,P=2048,ue=4096,B=8192,Z=16384,ze=32768,yn=1<<25,it=65536,Wt=1<<17,Bn=1<<18,_t=1<<19,Un=1<<20,fe=1<<25,je=65536,$t=1<<21,zt=1<<22,Te=1<<23,st=Symbol("$state"),pe=new class extends Error{constructor(){super(...arguments);be(this,"name","StaleReactionError");be(this,"message","The reaction that called `getAbortSignal()` was re-run or destroyed")}};function ls(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function as(){throw new Error("https://svelte.dev/e/async_derived_orphan")}function os(e,t,n){throw new Error("https://svelte.dev/e/each_key_duplicate")}function fs(e){throw new Error("https://svelte.dev/e/effect_in_teardown")}function us(){throw new Error("https://svelte.dev/e/effect_in_unowned_derived")}function cs(e){throw new Error("https://svelte.dev/e/effect_orphan")}function ds(){throw new Error("https://svelte.dev/e/effect_update_depth_exceeded")}function vs(){throw new Error("https://svelte.dev/e/state_descriptors_fixed")}function hs(){throw new Error("https://svelte.dev/e/state_prototype_fixed")}function ps(){throw new Error("https://svelte.dev/e/state_unsafe_mutation")}function Vn(e){return e===this.v}function _s(e,t){return e!=e?t==t:e!==t||e!==null&&typeof e=="object"||typeof e=="function"}function Yn(e){return!_s(e,this.v)}let D=null;function Dt(e){D=e}function gs(e,t=!1,n){D={p:D,i:!1,c:null,e:null,s:e,x:null,r:E,l:pt&&!t?{s:null,u:null,$:[]}:null}}function ms(e){var t=D,n=t.e;if(n!==null){t.e=null;for(var r of n)fr(r)}return t.i=!0,D=t.p,{}}function gt(){return!pt||D!==null&&D.l===null}let Ue=[];function ws(){var e=Ue;Ue=[],Gt(e)}function It(e){if(Ue.length===0){var t=Ue;queueMicrotask(()=>{t===Ue&&ws()})}Ue.push(e)}function xs(e){var t=E;if(t===null)return m.f|=Te,e;if((t.f&ze)===0&&(t.f&Xe)===0)throw e;Ft(e,t)}function Ft(e,t){for(;t!==null;){if((t.f&is)!==0){if((t.f&ze)===0)throw e;try{t.b.error(e);return}catch(n){e=n}}t=t.parent}throw e}const bs=-7169;function C(e,t){e.f=e.f&bs|t}function ln(e){(e.f&X)!==0||e.deps===null?C(e,N):C(e,ue)}function Kn(e){if(e!==null)for(const t of e)(t.f&I)===0||(t.f&je)===0||(t.f^=je,Kn(t.deps))}function ys(e,t,n){(e.f&P)!==0?t.add(e):(e.f&ue)!==0&&n.add(e),Kn(e.deps),C(e,N)}const ye=new Set;let w=null,L=null,Xt=null,Vt=!1,Ve=null,Mt=null;var En=0;let Es=1;var Ke,Ge,_e,le,ct,K,dt,Se,ge,ae,We,Fe,O,Ct,Gn,Rt,Zt,Jt,Wn;const Pt=class Pt{constructor(){M(this,O);be(this,"id",Es++);be(this,"current",new Map);be(this,"previous",new Map);M(this,Ke,new Set);M(this,Ge,new Set);M(this,_e,new Map);M(this,le,new Map);M(this,ct,null);M(this,K,[]);M(this,dt,[]);M(this,Se,new Set);M(this,ge,new Set);M(this,ae,new Map);be(this,"is_fork",!1);M(this,We,!1);M(this,Fe,new Set)}skip_effect(t){c(this,ae).has(t)||c(this,ae).set(t,{d:[],m:[]})}unskip_effect(t){var n=c(this,ae).get(t);if(n){c(this,ae).delete(t);for(var r of n.d)C(r,P),this.schedule(r);for(r of n.m)C(r,ue),this.schedule(r)}}capture(t,n,r=!1){n!==F&&!this.previous.has(t)&&this.previous.set(t,n),(t.f&Te)===0&&(this.current.set(t,[t.v,r]),LLine truncated
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AMCS</title>
<meta
name="description"
content="AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."
/>
<script type="module" crossorigin src="/assets/index-CIozj4p7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BDhMN0_9.css">
</head>
<body class="bg-slate-950">
<div id="app"></div>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
package app
import (
"embed"
"io/fs"
)
var (
//go:embed ui/dist
uiFiles embed.FS
uiDistFS fs.FS
indexHTML []byte
)
func init() {
dist, err := fs.Sub(uiFiles, "ui/dist")
if err != nil {
return
}
uiDistFS = dist
indexHTML, _ = fs.ReadFile(uiDistFS, "index.html")
}
+8 -1
View File
@@ -2,4 +2,11 @@
set -euo pipefail
go run ./cmd/amcs-server --config "${1:-configs/dev.yaml}"
CONFIG_PATH="${1:-configs/dev.yaml}"
if [[ ! -f internal/app/ui/dist/index.html ]]; then
echo "UI build not found; building frontend first..."
make ui-build
fi
go run ./cmd/amcs-server --config "$CONFIG_PATH"
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AMCS</title>
<meta
name="description"
content="AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."
/>
</head>
<body class="bg-slate-950">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2193
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "amcs-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.1.4",
"@types/node": "^24.5.2",
"svelte": "^5.28.2",
"svelte-check": "^4.1.6",
"tailwindcss": "^4.1.4",
"typescript": "^5.8.3",
"vite": "^6.3.2"
}
}
+178
View File
@@ -0,0 +1,178 @@
<script lang="ts">
import { onMount } from 'svelte';
type AccessEntry = {
key_id: string;
last_accessed_at: string;
last_path: string;
request_count: number;
};
type StatusResponse = {
title: string;
description: string;
version: string;
build_date: string;
commit: string;
connected_count: number;
total_known: number;
connected_window: string;
oauth_enabled: boolean;
entries: AccessEntry[];
};
let data: StatusResponse | null = null;
let loading = true;
let error = '';
const quickLinks = [
{ href: '/llm', label: 'LLM Instructions' },
{ href: '/healthz', label: 'Health Check' },
{ href: '/readyz', label: 'Readiness Check' }
];
async function loadStatus() {
loading = true;
error = '';
try {
const response = await fetch('/api/status');
if (!response.ok) {
throw new Error(`Status request failed with ${response.status}`);
}
data = (await response.json()) as StatusResponse;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load status';
} finally {
loading = false;
}
}
function formatDate(value: string) {
return new Date(value).toLocaleString();
}
onMount(loadStatus);
</script>
<svelte:head>
<title>AMCS</title>
</svelte:head>
<div class="min-h-screen bg-slate-950 text-slate-100">
<main class="mx-auto flex min-h-screen max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
<section class="overflow-hidden rounded-3xl border border-white/10 bg-slate-900 shadow-2xl shadow-slate-950/40">
<img src="/images/project.jpg" alt="Avelon Memory Crystal" class="h-64 w-full object-cover object-center sm:h-80" />
<div class="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1.6fr_1fr] lg:p-10">
<div class="space-y-6">
<div class="space-y-4">
<div class="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-sm font-medium text-cyan-200">
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
Avalon Memory Crystal Server
</div>
<div>
<h1 class="text-3xl font-semibold tracking-tight text-white sm:text-4xl">Avelon Memory Crystal Server (AMCS)</h1>
<p class="mt-3 max-w-3xl text-base leading-7 text-slate-300 sm:text-lg">
{data?.description ?? 'AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.'}
</p>
</div>
</div>
<div class="flex flex-wrap gap-3">
{#each quickLinks as link}
<a class="inline-flex items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-2 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20" href={link.href}>{link.label}</a>
{/each}
{#if data?.oauth_enabled}
<a class="inline-flex items-center justify-center rounded-xl border border-violet-300/20 bg-violet-400/10 px-4 py-2 text-sm font-semibold text-violet-100 transition hover:border-violet-300/40 hover:bg-violet-400/20" href="/oauth-authorization-server">OAuth Authorization Server</a>
{/if}
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Connected users</p>
<p class="mt-2 text-3xl font-semibold text-white">{data?.connected_count ?? '—'}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Known principals</p>
<p class="mt-2 text-3xl font-semibold text-white">{data?.total_known ?? '—'}</p>
</div>
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Version</p>
<p class="mt-2 break-all text-2xl font-semibold text-white">{data?.version ?? '—'}</p>
</div>
</div>
</div>
<aside class="space-y-4 rounded-2xl border border-white/10 bg-slate-950/50 p-5">
<div>
<h2 class="text-lg font-semibold text-white">Build details</h2>
<p class="mt-1 text-sm text-slate-400">The same status info as the old landing page, just without looking like it escaped from 2004.</p>
</div>
<dl class="space-y-3 text-sm text-slate-300">
<div>
<dt class="text-slate-500">Build date</dt>
<dd class="mt-1 font-medium text-white">{data?.build_date ?? 'unknown'}</dd>
</div>
<div>
<dt class="text-slate-500">Commit</dt>
<dd class="mt-1 break-all rounded-lg bg-white/5 px-3 py-2 font-mono text-xs text-cyan-100">{data?.commit ?? 'unknown'}</dd>
</div>
<div>
<dt class="text-slate-500">Connected window</dt>
<dd class="mt-1 font-medium text-white">{data?.connected_window ?? 'last 10 minutes'}</dd>
</div>
</dl>
</aside>
</div>
</section>
<section class="mt-6 rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Recent access</h2>
<p class="mt-1 text-sm text-slate-400">Authenticated principals AMCS has seen recently.</p>
</div>
<button class="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition hover:bg-white/10" on:click={loadStatus}>
Refresh
</button>
</div>
{#if loading}
<div class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400">Loading status…</div>
{:else if error}
<div class="mt-6 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-6 text-sm text-rose-100">
<p class="font-semibold">Couldn’t load the status snapshot.</p>
<p class="mt-1 text-rose-100/80">{error}</p>
</div>
{:else if data && data.entries.length === 0}
<div class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400">No authenticated access recorded yet.</div>
{:else if data}
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Principal</th>
<th class="px-4 py-3 font-medium">Last accessed</th>
<th class="px-4 py-3 font-medium">Last path</th>
<th class="px-4 py-3 font-medium">Requests</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each data.entries as entry}
<tr class="hover:bg-white/[0.03]">
<td class="px-4 py-3 align-top"><code class="rounded bg-white/5 px-2 py-1 font-mono text-xs text-cyan-100">{entry.key_id}</code></td>
<td class="px-4 py-3 align-top text-slate-200">{formatDate(entry.last_accessed_at)}</td>
<td class="px-4 py-3 align-top"><code class="text-slate-100">{entry.last_path}</code></td>
<td class="px-4 py-3 align-top font-semibold text-white">{entry.request_count}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</section>
</main>
</div>
+16
View File
@@ -0,0 +1,16 @@
@import 'tailwindcss';
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
}
+8
View File
@@ -0,0 +1,8 @@
import './app.css';
import App from './App.svelte';
const app = new App({
target: document.getElementById('app')!
});
export default app;
+5
View File
@@ -0,0 +1,5 @@
export default {
compilerOptions: {
dev: process.env.NODE_ENV !== 'production'
}
};
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.node.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"lib": ["ESNext", "DOM"],
"verbatimModuleSyntax": true,
"strict": true,
"allowJs": true,
"checkJs": false,
"types": ["svelte", "node"]
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
}
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import tailwindcss from '@tailwindcss/vite';
const backendTarget = process.env.AMCS_UI_BACKEND ?? 'http://127.0.0.1:8080';
export default defineConfig({
plugins: [svelte(), tailwindcss()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': backendTarget,
'/healthz': backendTarget,
'/readyz': backendTarget,
'/llm': backendTarget,
'/images': backendTarget,
'/favicon.ico': backendTarget,
'/mcp': backendTarget,
'/files': backendTarget,
'/oauth-authorization-server': backendTarget,
'/authorize': backendTarget,
'/oauth': backendTarget,
'/.well-known': backendTarget
}
},
build: {
outDir: '../internal/app/ui/dist',
emptyOutDir: true
}
});