rebase
This commit is contained in:
50
CHANGELOG.md
50
CHANGELOG.md
@@ -4,6 +4,56 @@ All notable changes to AI-RPG are documented here.
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.0] — 2026-06-21
|
||||
|
||||
This is a major bugfix release addressing 20+ issues found during user testing.
|
||||
|
||||
### Backend — Critical fixes
|
||||
|
||||
- **app/api/sessions.py**: fixed `TypeError: 'async_generator' object does not support the asynchronous context manager protocol` that broke ALL SSE streams (world builder, world editor, orchestrator). The `_session_scope()` function was an async generator (used `yield`) but was called with `async with`. Added `@asynccontextmanager` decorator. Without this fix, no LLM calls were ever made — the background task crashed immediately.
|
||||
- **app/models/__init__.py**: made `WorldPreset.owner_id` nullable (`NOT NULL` → `NULL`, `ondelete=CASCADE` → `SET NULL`). Builtin presets are now system presets with `owner_id=NULL`. Previously, `seed_builtin_presets` crashed on first startup with a foreign key violation because no admin user existed yet.
|
||||
- **app/main.py**: added `_apply_schema_fixups(engine)` that runs `ALTER TABLE world_presets ALTER COLUMN owner_id DROP NOT NULL` on every startup (idempotent, wrapped in try/except). This fixes existing databases that were created with the old NOT NULL constraint.
|
||||
- **app/main.py**: admin setup URL is now ALWAYS printed on startup (even if admin already exists), per user request. The URL is blocked by the backend if an admin exists, but the token is visible for reference.
|
||||
- **app/migrations/seed.py**: `seed_builtin_presets` now uses `owner_id=None` instead of looking for an admin user or a sentinel UUID.
|
||||
- **requirements.txt**: pinned `bcrypt==4.0.1`. bcrypt 4.1+ removed the `__about__` module which breaks passlib 1.7.4's version detection. This caused the annoying `(trapped) error reading bcrypt version` warning on every password hash operation.
|
||||
|
||||
### Backend — API improvements
|
||||
|
||||
- **app/api/misc.py**: added `GET /api/settings/public` (no auth) — returns `{page_title, favicon_url, logo_url, og_image_url}`. The frontend uses this on app load to set the document title, favicon, and navbar logo.
|
||||
- **app/api/worlds.py**: `GET /api/worlds` now excludes archived worlds by default. Pass `?status_filter=archived` to see only archived, or `?status_filter=all` to see everything.
|
||||
- **app/api/admin.py**: `GET /api/admin/stats` now returns separate counts: `worlds` (active), `worlds_archived`, `worlds_total`. Previously it counted ALL worlds including archived.
|
||||
- **app/api/admin.py**: added `DELETE /api/admin/worlds/{id}` — hard-delete a world (cascade deletes entities, steps, logs, triggers, story_entries). Also cleans up Qdrant points (best-effort). This gives admins a way to permanently remove archived worlds.
|
||||
- **app/core/llm.py**: improved error messages for LLM API failures:
|
||||
- Non-JSON responses now raise `LLMResponseError("LLM provider returned non-JSON response (check that api_url points to an OpenAI-compatible endpoint). First 200 chars: ...")` instead of a generic `parse_error`.
|
||||
- HTTP 4xx/5xx errors now include the response body (or extracted `error.message` from JSON).
|
||||
- All error messages now include the HTTP status code for easier debugging.
|
||||
|
||||
### Frontend — Critical fixes (19 files changed, 1 new)
|
||||
|
||||
- **Black page after settings change**: root cause was `AdminApi.updateSettings` returning `{updated: ...}` but the store expecting a different shape. Fixed the return type and removed the refetch-after-save that caused re-render loops.
|
||||
- **[object Object] error in chat**: added `toErrorMessage()` helper that properly extracts `.message` from `ApiError` objects. All error toasts now show human-readable strings.
|
||||
- **Play page access control**: `/worlds/:id/play` now redirects to `/worlds/:id/edit` with a toast if `world.status !== 'ready'`.
|
||||
- **Retry/Rollback buttons**: disabled when `recentSteps.length === 0` with `opacity-50 cursor-not-allowed`.
|
||||
- **Archived worlds**: hidden from the worlds list (both server-side and client-side filter). After deleting, removed from local state immediately.
|
||||
- **World card archived state**: no Play button, "Archived" badge, "Restore" button (`PATCH` status→ready), admin-only "Delete permanently" button (`DELETE /api/admin/worlds/{id}`).
|
||||
- **LLM logs detail modal**: now shows `request_messages`, `response_message`, `tool_calls`, `error_message`, `prompt_tokens`, `completion_tokens`, `latency_ms`, `temperature`, `model`, `stage`, `status` (color-coded), `created_at` — all in pretty-printed JSON `<pre>` blocks.
|
||||
- **Dropdowns**: `embeddings.provider` → `<select>` with `offline_hash`/`openai`; boolean settings → `<select>` with `true`/`false`; LLM log stage filter → `<select>` with all 16 stage values; LLM log status filter → `<select>` with all 6 status values.
|
||||
- **Numeric inputs**: all integer/float settings now use `<input type="number">` with `min`/`max` attributes. String-to-number conversion on save.
|
||||
- **Test page pre-fills**: diagnostic forms now pre-fill from current settings (`llm.api_url`, `llm.api_key`, `llm.model`, `embeddings.api_url`, `embeddings.model`, `embeddings.provider`).
|
||||
- **Embeddings test provider**: defaults to current `embeddings.provider` setting (was hardcoded to `openai`). Added hint: "If provider=openai and api_url is empty, falls back to llm.api_url".
|
||||
- **Embeddings URL fallback hints**: added muted hint text next to `embeddings.api_url`, `embeddings.api_key`, `embeddings.model` fields.
|
||||
- **UI settings applied**: new `uiSettingsStore` fetches `GET /api/settings/public` on app load. Sets `document.title`, favicon `<link>`, and navbar logo. Re-fetches after admin settings save.
|
||||
- **Light theme fix**: rewrote CSS with CSS-variable-based theming. Light mode now uses `#fafafa`/`#1a1a1a`/`#ffffff` with high-contrast text. Dark mode unchanged.
|
||||
- **Single Create button**: removed redundant "Create World" from navbar. Only one Create button remains (top of worlds list page).
|
||||
- **Username validation**: client-side regex check + 422 error parsing with field-level inline messages. Specific toast: "Username can only contain letters, numbers, and underscores".
|
||||
- **SSE error handling**: `sessionStore` now shows toast on SSE `error` events. One-shot "Connection lost. Retrying…" toast on unexpected stream closure.
|
||||
- **Settings panel UX**: 6 grouped cards (LLM / Embeddings / Qdrant / Context / Game / UI), each with its own Save button. No page reload or full refetch after save — just a success toast.
|
||||
- **Admin stats panel**: now shows 6 cards: Users, Active Worlds, Archived, Total Worlds, Steps, Avg LLM Latency.
|
||||
|
||||
### Verification
|
||||
- Backend: 68 unit tests pass.
|
||||
- Frontend: `tsc --noEmit` → 0 errors. `npm run build` → success (354 KB JS / 23 KB CSS, ~109 KB gzipped).
|
||||
|
||||
## [1.0.5] — 2026-06-20
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -152,6 +152,34 @@ async def patch_user(
|
||||
return {"id": str(user.id), "is_admin": user.is_admin, "is_active": user.is_active}
|
||||
|
||||
|
||||
@router.delete("/worlds/{world_id}", status_code=200)
|
||||
async def hard_delete_world(
|
||||
world_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
"""Hard-delete a world (cascade deletes all related entities, steps, logs).
|
||||
|
||||
Also cleans up Qdrant points for the world (best-effort).
|
||||
"""
|
||||
from app.models import World
|
||||
from app.core.qdrant_client import cleanup_world_points
|
||||
|
||||
world = (
|
||||
await db.execute(select(World).where(World.id == world_id))
|
||||
).scalar_one_or_none()
|
||||
if world is None:
|
||||
raise HTTPException(404, "not_found")
|
||||
await db.delete(world)
|
||||
await db.commit()
|
||||
# Best-effort Qdrant cleanup
|
||||
try:
|
||||
await cleanup_world_points(str(world_id))
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("qdrant_cleanup_failed", world_id=str(world_id), error=str(e))
|
||||
return {"ok": True, "deleted": str(world_id)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Stats
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -163,14 +191,27 @@ async def stats(
|
||||
from app.models import Step, World
|
||||
|
||||
users_count = (await db.execute(select(func.count(User.id)))).scalar_one()
|
||||
worlds_count = (await db.execute(select(func.count(World.id)))).scalar_one()
|
||||
# Active worlds (exclude archived)
|
||||
active_worlds = (
|
||||
await db.execute(
|
||||
select(func.count(World.id)).where(World.status != "archived")
|
||||
)
|
||||
).scalar_one()
|
||||
archived_worlds = (
|
||||
await db.execute(
|
||||
select(func.count(World.id)).where(World.status == "archived")
|
||||
)
|
||||
).scalar_one()
|
||||
total_worlds = active_worlds + archived_worlds
|
||||
steps_count = (await db.execute(select(func.count(Step.id)))).scalar_one()
|
||||
avg_latency = (
|
||||
await db.execute(select(func.avg(LlmCallLog.latency_ms)))
|
||||
).scalar_one()
|
||||
return {
|
||||
"users": users_count,
|
||||
"worlds": worlds_count,
|
||||
"worlds": active_worlds,
|
||||
"worlds_total": total_worlds,
|
||||
"worlds_archived": archived_worlds,
|
||||
"steps": steps_count,
|
||||
"avg_llm_latency_ms": float(avg_latency) if avg_latency else 0,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Misc endpoints: health, i18n."""
|
||||
"""Misc endpoints: health, i18n, public settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.config import get_settings
|
||||
from app.core.embeddings import HashEmbedder
|
||||
from app.core.logging import get_logger
|
||||
from app.core.qdrant_client import ping_qdrant
|
||||
from app.core.settings_service import get_all_settings, get_setting
|
||||
from app.db import get_db
|
||||
from app.schemas import HealthResponse
|
||||
|
||||
@@ -34,12 +35,9 @@ async def health(db: AsyncSession = Depends(get_db)) -> HealthResponse:
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("health_qdrant_failed", error=str(e))
|
||||
|
||||
# LLM health: we treat it as "true" only if api_url is set AND we can avoid a real call.
|
||||
# For the basic health probe we don't make any LLM calls — return True iff api_url is configured.
|
||||
cfg = get_settings()
|
||||
llm_ok = bool(cfg.llm_api_url)
|
||||
|
||||
# Embeddings: True if HashEmbedder works (always does) or if openai provider configured
|
||||
embeddings_ok = True
|
||||
if cfg.embeddings_provider == "offline_hash":
|
||||
try:
|
||||
@@ -59,16 +57,28 @@ async def health(db: AsyncSession = Depends(get_db)) -> HealthResponse:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings/public")
|
||||
async def public_settings(db: AsyncSession = Depends(get_db)) -> dict:
|
||||
"""Return UI-relevant settings (no secrets). No auth required.
|
||||
|
||||
The frontend uses this on app load to set the page title, favicon,
|
||||
and logo.
|
||||
"""
|
||||
page_title = await get_setting(db, "ui.page_title")
|
||||
favicon_url = await get_setting(db, "ui.favicon_url")
|
||||
logo_url = await get_setting(db, "ui.logo_url")
|
||||
og_image_url = await get_setting(db, "ui.og_image_url")
|
||||
return {
|
||||
"page_title": page_title or "AI-RPG",
|
||||
"favicon_url": favicon_url or "/icon.png",
|
||||
"logo_url": logo_url or "/icon.png",
|
||||
"og_image_url": og_image_url or "",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/i18n/{lang}")
|
||||
async def i18n(lang: str) -> dict:
|
||||
"""Return translation JSON for the given language.
|
||||
|
||||
Backend only knows the en/ru bundles used by the frontend; we serve them
|
||||
statically from the frontend's `public/i18n/` folder in production, but
|
||||
this endpoint is useful for hot-reloading in dev.
|
||||
"""
|
||||
"""Return translation JSON for the given language."""
|
||||
if lang not in ("en", "ru"):
|
||||
return {"error": "unsupported language"}
|
||||
# The frontend owns the bundles; this endpoint returns an empty dict
|
||||
# (the frontend fetches `/i18n/{lang}.json` as a static asset).
|
||||
return {"language": lang}
|
||||
|
||||
@@ -339,8 +339,18 @@ def _format_sse(evt: dict[str, str]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session_scope():
|
||||
"""Open a fresh DB session for the background task."""
|
||||
"""Open a fresh DB session for the background task.
|
||||
|
||||
Must be used as: async with _session_scope() as bg_db: ...
|
||||
The @asynccontextmanager decorator is required — without it, an
|
||||
`async def` with `yield` returns an async generator, which does NOT
|
||||
support `async with`.
|
||||
"""
|
||||
from app.db import get_sessionmaker
|
||||
|
||||
sm = get_sessionmaker()
|
||||
|
||||
@@ -49,9 +49,16 @@ async def list_worlds(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""List the current user's worlds."""
|
||||
"""List the current user's worlds.
|
||||
|
||||
By default, archived worlds are excluded. Pass status_filter='archived'
|
||||
to see only archived, or status_filter='all' to see everything.
|
||||
"""
|
||||
stmt = select(World).where(World.owner_id == user.id)
|
||||
if status_filter and status_filter != "all":
|
||||
if not status_filter or status_filter == "active":
|
||||
# Default: exclude archived
|
||||
stmt = stmt.where(World.status != "archived")
|
||||
elif status_filter != "all":
|
||||
stmt = stmt.where(World.status == status_filter)
|
||||
total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
|
||||
stmt = stmt.order_by(World.last_played_at.desc().nullslast(), World.created_at.desc())
|
||||
|
||||
@@ -141,16 +141,36 @@ class LlmClient:
|
||||
)
|
||||
if resp.status_code >= 500:
|
||||
raise LLMUnavailableError(
|
||||
f"LLM provider returned {resp.status_code}: {resp.text[:200]}"
|
||||
f"LLM provider returned HTTP {resp.status_code}: {resp.text[:300]}"
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
raise LLMUnavailableError("LLM provider rate-limited (429)")
|
||||
raise LLMUnavailableError("LLM provider rate-limited (HTTP 429)")
|
||||
if resp.status_code >= 400:
|
||||
# Try to extract error message from JSON body
|
||||
err_body = resp.text[:500]
|
||||
try:
|
||||
err_json = resp.json()
|
||||
if "error" in err_json:
|
||||
err_msg = err_json["error"].get("message", err_body)
|
||||
else:
|
||||
err_msg = err_body
|
||||
except Exception: # noqa: BLE001
|
||||
err_msg = err_body
|
||||
raise LLMResponseError(
|
||||
f"LLM provider returned {resp.status_code}: {resp.text[:500]}",
|
||||
f"LLM provider returned HTTP {resp.status_code}: {err_msg}",
|
||||
code="api_error",
|
||||
)
|
||||
data = resp.json()
|
||||
# Parse JSON response — if this fails, the URL is likely wrong
|
||||
# (pointing at an HTML page instead of an OpenAI-compatible API)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise LLMResponseError(
|
||||
f"LLM provider returned non-JSON response (check that "
|
||||
f"api_url points to an OpenAI-compatible endpoint). "
|
||||
f"First 200 chars: {resp.text[:200]}",
|
||||
code="parse_error",
|
||||
) from e
|
||||
break
|
||||
except (httpx.TimeoutException, asyncio.TimeoutError) as e:
|
||||
last_exc = LLMTimeoutError(str(e))
|
||||
|
||||
44
app/main.py
44
app/main.py
@@ -27,6 +27,25 @@ from app.models import Setting, User
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
async def _apply_schema_fixups(engine) -> None:
|
||||
"""Apply idempotent ALTER statements for schema fixes that create_all
|
||||
cannot handle (e.g. changing NOT NULL → NULL on existing tables).
|
||||
|
||||
These run on every startup. Each statement is wrapped in try/except so
|
||||
it silently succeeds if the fix was already applied.
|
||||
"""
|
||||
fixups = [
|
||||
# world_presets.owner_id: was NOT NULL, now nullable (for system presets)
|
||||
"ALTER TABLE world_presets ALTER COLUMN owner_id DROP NOT NULL",
|
||||
]
|
||||
async with engine.begin() as conn:
|
||||
for sql in fixups:
|
||||
try:
|
||||
await conn.execute(text(sql))
|
||||
except Exception: # noqa: BLE001
|
||||
pass # already applied, or table doesn't exist yet
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Application startup / shutdown lifecycle."""
|
||||
@@ -44,10 +63,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
try:
|
||||
engine = get_engine()
|
||||
await create_all_tables(engine)
|
||||
await _apply_schema_fixups(engine)
|
||||
_logger.info("db_tables_ready")
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.error("db_tables_create_failed", error=str(e))
|
||||
# Continue anyway — /api/health will reflect the broken state
|
||||
|
||||
sm = get_sessionmaker()
|
||||
try:
|
||||
@@ -56,7 +75,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
await seed_default_settings(session)
|
||||
# 2) ensure admin setup token
|
||||
token = await get_admin_setup_token(session)
|
||||
# 3) check if any admin exists
|
||||
# 3) check if any admin exists — ALWAYS print the setup URL
|
||||
# (user requested: even if admin exists, show the token for
|
||||
# reference / debugging)
|
||||
from sqlalchemy import func
|
||||
|
||||
admins_count = (
|
||||
@@ -65,16 +86,18 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
)
|
||||
).scalar_one()
|
||||
if admins_count == 0:
|
||||
_logger.warning(
|
||||
"no_admin_yet",
|
||||
setup_url=f"/register/admin?token={token}",
|
||||
)
|
||||
print(f"\n=== AI-RPG Admin Setup ===")
|
||||
print(f"No admin user yet. Open this URL in your browser:")
|
||||
print(f" /register/admin?token={token}")
|
||||
print(f"===========================\n")
|
||||
_logger.warning("no_admin_yet", setup_url=f"/register/admin?token={token}")
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" AI-RPG — No admin user yet.")
|
||||
print(f" Open this URL to create the first admin:")
|
||||
print(f" http://localhost:8080/register/admin?token={token}")
|
||||
print(f"{'=' * 60}\n")
|
||||
else:
|
||||
_logger.info("admins_present", count=admins_count)
|
||||
# Still print the token URL for reference
|
||||
print(f"\n Admin already exists. Admin register URL (for reference):")
|
||||
print(f" /register/admin?token={token}")
|
||||
print(f" (This URL is blocked since an admin already exists.)\n")
|
||||
# 4) seed builtin presets (idempotent)
|
||||
from app.migrations.seed import seed_builtin_presets
|
||||
|
||||
@@ -94,7 +117,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
_logger.warning("qdrant_init_failed", error=str(e))
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.error("startup_failed", error=str(e))
|
||||
# Don't crash — let /api/health reflect the broken state
|
||||
pass
|
||||
|
||||
yield
|
||||
|
||||
@@ -245,7 +245,12 @@ BUILTIN_PRESETS = [FANTASY_PRESET, SCI_FI_PRESET]
|
||||
|
||||
|
||||
async def seed_builtin_presets(session: AsyncSession) -> None:
|
||||
"""Insert builtin presets if they don't yet exist. Owned by the first admin (or a system sentinel)."""
|
||||
"""Insert builtin presets if they don't yet exist.
|
||||
|
||||
Builtin presets have owner_id=NULL (they are system presets, not owned by
|
||||
any specific user). This avoids the FK violation that occurred on first
|
||||
startup when no admin user existed yet.
|
||||
"""
|
||||
for preset_data in BUILTIN_PRESETS:
|
||||
existing = (
|
||||
await session.execute(
|
||||
@@ -254,18 +259,8 @@ async def seed_builtin_presets(session: AsyncSession) -> None:
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
continue
|
||||
# Find any admin to own the preset, or use a sentinel UUID
|
||||
from app.models import User
|
||||
from sqlalchemy import func
|
||||
|
||||
admin = (
|
||||
await session.execute(
|
||||
select(User).where(User.is_admin.is_(True)).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
owner_id = admin.id if admin else uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
preset = WorldPreset(
|
||||
owner_id=owner_id,
|
||||
owner_id=None, # system preset — no owner
|
||||
**preset_data,
|
||||
version=1,
|
||||
)
|
||||
|
||||
@@ -91,8 +91,11 @@ class WorldPreset(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
# owner_id is nullable so that builtin/system presets can exist without
|
||||
# a real user owning them. Custom presets created via the admin UI get
|
||||
# the admin's user_id.
|
||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -112,7 +115,7 @@ class WorldPreset(Base):
|
||||
DateTime(timezone=True), nullable=False, default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
owner: Mapped[User] = relationship(back_populates="presets")
|
||||
owner: Mapped[User | None] = relationship(back_populates="presets")
|
||||
worlds: Mapped[list["World"]] = relationship(back_populates="preset")
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useUiSettingsStore } from "@/stores/uiSettingsStore";
|
||||
import { Navbar } from "@/components/ui/Navbar";
|
||||
import { ToastViewport } from "@/components/ui/Toast";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
@@ -58,9 +59,18 @@ function ScrollToTop() {
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const uiSettings = useUiSettingsStore((s) => s.settings);
|
||||
// On mount, fetch public UI settings (page title, favicon, logo).
|
||||
const fetchUiSettings = useUiSettingsStore((s) => s.fetch);
|
||||
useEffect(() => {
|
||||
document.title = t("common.app_name");
|
||||
}, [t]);
|
||||
void fetchUiSettings();
|
||||
}, [fetchUiSettings]);
|
||||
|
||||
// Keep document.title in sync with public settings (or default to app name).
|
||||
useEffect(() => {
|
||||
if (uiSettings?.page_title) document.title = uiSettings.page_title;
|
||||
else document.title = t("common.app_name");
|
||||
}, [uiSettings?.page_title, t]);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { AdminApi, toErrorMessage } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { LlmLog, LlmLogDetail, Paginated } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
@@ -9,6 +9,34 @@ import { Input } from "@/components/ui/Input";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
const STAGE_OPTIONS = [
|
||||
"",
|
||||
"world_builder_schema",
|
||||
"world_builder_env",
|
||||
"world_builder_entities",
|
||||
"world_editor",
|
||||
"orchestrator_phase1",
|
||||
"orchestrator_phase2",
|
||||
"orchestrator_phase3_summary",
|
||||
"orchestrator_phase3_suggest",
|
||||
"intro_scene",
|
||||
"subagent",
|
||||
"summary",
|
||||
"test_llm",
|
||||
"test_llm_tools",
|
||||
"test_embeddings",
|
||||
"test_embeddings_probe_dimension",
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = ["", "ok", "timeout", "api_error", "parse_error", "validation_error"];
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === "ok") return "bg-ok/15 text-ok";
|
||||
const errKinds = ["timeout", "api_error", "parse_error", "validation_error", "error"];
|
||||
if (errKinds.includes(status)) return "bg-err/15 text-err";
|
||||
return "bg-warn/15 text-warn";
|
||||
}
|
||||
|
||||
export function LlmLogsTable() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
@@ -35,7 +63,7 @@ export function LlmLogsTable() {
|
||||
});
|
||||
setData(res);
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -53,11 +81,12 @@ export function LlmLogsTable() {
|
||||
const openDetail = async (id: string) => {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
const d = await AdminApi.llmLog(id);
|
||||
setDetail(d);
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err));
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
@@ -73,18 +102,32 @@ export function LlmLogsTable() {
|
||||
onChange={(e) => setFilters({ ...filters, world_id: e.target.value })}
|
||||
placeholder="uuid"
|
||||
/>
|
||||
<Input
|
||||
label={t("admin.logs_filter_stage")}
|
||||
value={filters.stage}
|
||||
onChange={(e) => setFilters({ ...filters, stage: e.target.value })}
|
||||
placeholder="world_builder / iteration / ..."
|
||||
/>
|
||||
<Input
|
||||
label={t("admin.logs_filter_status")}
|
||||
value={filters.status_filter}
|
||||
onChange={(e) => setFilters({ ...filters, status_filter: e.target.value })}
|
||||
placeholder="success / error"
|
||||
/>
|
||||
<div className="w-full">
|
||||
<label className="label" htmlFor="filter-stage">{t("admin.logs_filter_stage")}</label>
|
||||
<select
|
||||
id="filter-stage"
|
||||
className="input"
|
||||
value={filters.stage}
|
||||
onChange={(e) => setFilters({ ...filters, stage: e.target.value })}
|
||||
>
|
||||
{STAGE_OPTIONS.map((s) => (
|
||||
<option key={s || "_empty"} value={s}>{s || "(any)"}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<label className="label" htmlFor="filter-status">{t("admin.logs_filter_status")}</label>
|
||||
<select
|
||||
id="filter-status"
|
||||
className="input"
|
||||
value={filters.status_filter}
|
||||
onChange={(e) => setFilters({ ...filters, status_filter: e.target.value })}
|
||||
>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s || "_empty"} value={s}>{s || "(any)"}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={applyFilters} variant="secondary" fullWidth>
|
||||
{t("admin.logs_filter_apply")}
|
||||
@@ -118,15 +161,7 @@ export function LlmLogsTable() {
|
||||
<tr key={log.id} className="border-b border-fg-dim/10 hover:bg-bg-soft">
|
||||
<td className="p-2 font-mono text-xs">{log.stage}</td>
|
||||
<td className="p-2">
|
||||
<span
|
||||
className={`badge ${
|
||||
log.status === "success"
|
||||
? "bg-ok/15 text-ok"
|
||||
: log.status === "error"
|
||||
? "bg-err/15 text-err"
|
||||
: "bg-warn/15 text-warn"
|
||||
}`}
|
||||
>
|
||||
<span className={`badge ${statusColor(log.status)}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
</td>
|
||||
@@ -134,7 +169,7 @@ export function LlmLogsTable() {
|
||||
{log.latency_ms != null ? `${log.latency_ms} ms` : "—"}
|
||||
</td>
|
||||
<td className="p-2 text-fg-muted">
|
||||
{log.tokens != null ? log.tokens : "—"}
|
||||
{formatTokens(log)}
|
||||
</td>
|
||||
<td className="p-2 text-fg-muted">{formatDate(log.created_at)}</td>
|
||||
<td className="p-2 text-right">
|
||||
@@ -187,37 +222,64 @@ export function LlmLogsTable() {
|
||||
</div>
|
||||
) : detail ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<Field label={t("admin.logs_stage")} value={detail.stage} />
|
||||
<Field label={t("admin.logs_status")} value={detail.status} />
|
||||
<Field
|
||||
label={t("admin.logs_status")}
|
||||
value={detail.status}
|
||||
badgeClass={statusColor(detail.status)}
|
||||
/>
|
||||
<Field label={t("admin.model")} value={detail.model || "—"} />
|
||||
<Field
|
||||
label={t("admin.logs_latency")}
|
||||
value={detail.latency_ms != null ? `${detail.latency_ms} ms` : "—"}
|
||||
/>
|
||||
<Field
|
||||
label={t("admin.logs_tokens")}
|
||||
value={detail.tokens != null ? String(detail.tokens) : "—"}
|
||||
label="prompt_tokens"
|
||||
value={detail.prompt_tokens != null ? String(detail.prompt_tokens) : "—"}
|
||||
/>
|
||||
<Field
|
||||
label="completion_tokens"
|
||||
value={detail.completion_tokens != null ? String(detail.completion_tokens) : "—"}
|
||||
/>
|
||||
<Field
|
||||
label="temperature"
|
||||
value={detail.temperature != null ? String(detail.temperature) : "—"}
|
||||
/>
|
||||
<Field
|
||||
label="tool_calls"
|
||||
value={detail.tool_calls == null
|
||||
? "—"
|
||||
: Array.isArray(detail.tool_calls)
|
||||
? `${detail.tool_calls.length} call(s)`
|
||||
: "present"}
|
||||
/>
|
||||
<Field label={t("common.name")} value={detail.model || "—"} />
|
||||
<Field label={t("admin.logs_created")} value={formatDate(detail.created_at)} />
|
||||
</div>
|
||||
{detail.error && (
|
||||
<Section title={t("admin.logs_error")}>
|
||||
<pre className="whitespace-pre-wrap text-err">{detail.error}</pre>
|
||||
</Section>
|
||||
)}
|
||||
{detail.prompt && (
|
||||
<Section title={t("admin.logs_prompt")}>
|
||||
<pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs">
|
||||
{detail.prompt}
|
||||
</pre>
|
||||
</Section>
|
||||
)}
|
||||
{detail.response && (
|
||||
<Section title={t("admin.logs_response")}>
|
||||
<pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs">
|
||||
{detail.response}
|
||||
</pre>
|
||||
|
||||
<Section title={t("admin.logs_error")}>
|
||||
{detail.error_message ? (
|
||||
<pre className="whitespace-pre-wrap text-err">{detail.error_message}</pre>
|
||||
) : (
|
||||
<p className="text-fg-muted">—</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="request_messages">
|
||||
<JsonBlock value={detail.request_messages} />
|
||||
</Section>
|
||||
|
||||
<Section title="response_message">
|
||||
<JsonBlock value={detail.response_message} />
|
||||
</Section>
|
||||
|
||||
<Section title="tool_calls">
|
||||
<JsonBlock value={detail.tool_calls} />
|
||||
</Section>
|
||||
|
||||
{detail.request_tools != null && (
|
||||
<Section title="request_tools">
|
||||
<JsonBlock value={detail.request_tools} />
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
@@ -229,11 +291,43 @@ export function LlmLogsTable() {
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
function formatTokens(log: LlmLog): string {
|
||||
const p = log.prompt_tokens;
|
||||
const c = log.completion_tokens;
|
||||
if (p != null && c != null) return `${p} / ${c}`;
|
||||
if (p != null) return String(p);
|
||||
if (c != null) return String(c);
|
||||
// Legacy `tokens` field may exist on older rows.
|
||||
const t = (log as { tokens?: number | null }).tokens;
|
||||
return t != null ? String(t) : "—";
|
||||
}
|
||||
|
||||
function JsonBlock({ value }: { value: unknown }) {
|
||||
if (value == null) {
|
||||
return <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs text-fg-muted">—</pre>;
|
||||
}
|
||||
let text: string;
|
||||
try {
|
||||
text = typeof value === "string" ? JSON.stringify(JSON.parse(value), null, 2) : JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
text = String(value);
|
||||
}
|
||||
return (
|
||||
<pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-xs">
|
||||
{text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, badgeClass }: { label: string; value: string; badgeClass?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs uppercase text-fg-dim">{label}</p>
|
||||
<p className="text-fg break-all">{value}</p>
|
||||
{badgeClass ? (
|
||||
<span className={`badge mt-0.5 ${badgeClass}`}>{value}</span>
|
||||
) : (
|
||||
<p className="text-fg break-all">{value}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,26 +2,111 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { refreshUiSettings } from "@/stores/uiSettingsStore";
|
||||
import type { AdminSettingsResponse } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
const GROUP_PREFIXES: Array<{ group: string; prefixes: string[]; labelKey: string }> = [
|
||||
{ group: "llm", prefixes: ["llm_", "llm."], labelKey: "admin.group_llm" },
|
||||
{ group: "embeddings", prefixes: ["embeddings_", "embeddings."], labelKey: "admin.group_embeddings" },
|
||||
{ group: "qdrant", prefixes: ["qdrant_", "qdrant."], labelKey: "admin.group_qdrant" },
|
||||
{ group: "ui", prefixes: ["ui_", "ui.", "site_", "site."], labelKey: "admin.group_ui" },
|
||||
{ group: "game", prefixes: ["game_", "game."], labelKey: "admin.group_game" },
|
||||
interface GroupDef {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
/** Match by setting-key prefix (e.g. "llm." or "llm_"). */
|
||||
prefixes: string[];
|
||||
}
|
||||
|
||||
const GROUPS: GroupDef[] = [
|
||||
{ id: "llm", labelKey: "admin.group_llm", prefixes: ["llm.", "llm_"] },
|
||||
{ id: "embeddings", labelKey: "admin.group_embeddings", prefixes: ["embeddings.", "embeddings_"] },
|
||||
{ id: "qdrant", labelKey: "admin.group_qdrant", prefixes: ["qdrant.", "qdrant_"] },
|
||||
{ id: "context", labelKey: "admin.group_context", prefixes: ["context.", "context_"] },
|
||||
{ id: "game", labelKey: "admin.group_game", prefixes: ["game.", "game_"] },
|
||||
{ id: "ui", labelKey: "admin.group_ui", prefixes: ["ui.", "ui_", "site.", "site_"] },
|
||||
];
|
||||
|
||||
function groupFor(key: string): string {
|
||||
function groupFor(key: string): GroupDef | null {
|
||||
const lower = key.toLowerCase();
|
||||
for (const g of GROUP_PREFIXES) {
|
||||
if (g.prefixes.some((p) => lower.startsWith(p))) return g.group;
|
||||
for (const g of GROUPS) {
|
||||
if (g.prefixes.some((p) => lower.startsWith(p))) return g;
|
||||
}
|
||||
return "other";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Field types — drives which control is rendered. */
|
||||
type FieldType = "integer" | "float" | "boolean" | "provider" | "secret" | "text";
|
||||
|
||||
const INTEGER_KEYS = new Set<string>([
|
||||
"llm.max_tokens",
|
||||
"llm.timeout_seconds",
|
||||
"embeddings.dimension",
|
||||
"embeddings.batch_size",
|
||||
"embeddings.timeout_seconds",
|
||||
"embeddings.cache_ttl_seconds",
|
||||
"embeddings.max_text_chars",
|
||||
"context.guaranteed_messages",
|
||||
"context.compression_threshold_messages",
|
||||
"context.compression_threshold_tokens",
|
||||
"context.scene_text_truncate_tokens",
|
||||
"context.safety_margin_tokens",
|
||||
"game.max_substeps_per_iteration",
|
||||
"game.max_suggested_actions",
|
||||
]);
|
||||
|
||||
const FLOAT_KEYS = new Set<string>([
|
||||
"llm.temperature_orchestrator",
|
||||
"llm.temperature_writer",
|
||||
]);
|
||||
|
||||
const BOOLEAN_KEYS = new Set<string>([
|
||||
"game.deferred_triggers_enabled",
|
||||
"context.auto_rag_on_entity_mention",
|
||||
]);
|
||||
|
||||
const SECRET_KEYS = new Set<string>([
|
||||
"llm.api_key",
|
||||
"embeddings.api_key",
|
||||
"qdrant.api_key",
|
||||
"admin.setup_token",
|
||||
]);
|
||||
|
||||
const PROVIDER_KEYS = new Set<string>(["embeddings.provider"]);
|
||||
|
||||
function fieldType(key: string): FieldType {
|
||||
if (PROVIDER_KEYS.has(key)) return "provider";
|
||||
if (BOOLEAN_KEYS.has(key)) return "boolean";
|
||||
if (INTEGER_KEYS.has(key)) return "integer";
|
||||
if (FLOAT_KEYS.has(key)) return "float";
|
||||
if (SECRET_KEYS.has(key)) return "secret";
|
||||
return "text";
|
||||
}
|
||||
|
||||
/** Returns the appropriate hint text for a given setting key, if any. */
|
||||
function hintFor(key: string): string | undefined {
|
||||
switch (key) {
|
||||
case "embeddings.api_url":
|
||||
case "embeddings.api_key":
|
||||
return "If empty, falls back to llm.api_url / llm.api_key";
|
||||
case "embeddings.model":
|
||||
return "Default: text-embedding-3-small";
|
||||
case "embeddings.provider":
|
||||
return "If provider=openai and api_url is empty, the system falls back to llm.api_url";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function castValue(key: string, raw: string): string {
|
||||
const ft = fieldType(key);
|
||||
if (ft === "integer") {
|
||||
const n = parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? String(n) : raw;
|
||||
}
|
||||
if (ft === "float") {
|
||||
const n = parseFloat(raw);
|
||||
return Number.isFinite(n) ? String(n) : raw;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function SettingsPanel() {
|
||||
@@ -29,52 +114,88 @@ export function SettingsPanel() {
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const [data, setData] = useState<AdminSettingsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
AdminApi.settings()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setData(res);
|
||||
setDraft({ ...res.settings });
|
||||
})
|
||||
.catch(() => pushToast("error", t("admin.settings_load_failed")))
|
||||
.finally(() => setLoading(false));
|
||||
}, [pushToast, t]);
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : "Failed to load settings";
|
||||
pushToast("error", msg);
|
||||
})
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pushToast]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
if (!data) return {} as Record<string, Array<{ key: string; description?: string }>>;
|
||||
const out: Record<string, Array<{ key: string; description?: string }>> = {};
|
||||
if (!data) return out;
|
||||
for (const g of GROUPS) out[g.id] = [];
|
||||
for (const key of Object.keys(data.settings)) {
|
||||
const g = groupFor(key);
|
||||
(out[g] ||= []).push({ key, description: data.descriptions?.[key] });
|
||||
if (!g) continue;
|
||||
(out[g.id] ||= []).push({ key, description: data.descriptions?.[key] });
|
||||
}
|
||||
// Sort each group's keys alphabetically for stable display.
|
||||
for (const g of Object.keys(out)) {
|
||||
out[g].sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
return out;
|
||||
}, [data]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSaveGroup = async (groupId: string) => {
|
||||
if (!data) return;
|
||||
setSaving(true);
|
||||
const entries = grouped[groupId] || [];
|
||||
const groupKeys = new Set(entries.map((e) => e.key));
|
||||
// Build a diff of changed keys in this group only.
|
||||
const diff: Record<string, string> = {};
|
||||
for (const k of groupKeys) {
|
||||
const before = data.settings[k] ?? "";
|
||||
const after = draft[k] ?? "";
|
||||
if (before !== after) {
|
||||
diff[k] = castValue(k, after);
|
||||
}
|
||||
}
|
||||
if (Object.keys(diff).length === 0) {
|
||||
pushToast("info", "No changes to save.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Save only changed keys
|
||||
const diff: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(draft)) {
|
||||
if (data.settings[k] !== v) diff[k] = v;
|
||||
}
|
||||
if (Object.keys(diff).length === 0) {
|
||||
pushToast("info", "No changes to save.");
|
||||
return;
|
||||
}
|
||||
const res = await AdminApi.updateSettings(diff);
|
||||
setData(res);
|
||||
setDraft({ ...res.settings });
|
||||
// Merge returned (possibly masked) values back into local state WITHOUT
|
||||
// replacing the whole settings object — this is what was causing the
|
||||
// black-screen re-render loop.
|
||||
const nextSettings = { ...data.settings };
|
||||
for (const [k, v] of Object.entries(res.updated)) {
|
||||
nextSettings[k] = v;
|
||||
}
|
||||
setData({ ...data, settings: nextSettings });
|
||||
// Update draft to reflect masked values so future diffs are accurate.
|
||||
setDraft((d) => {
|
||||
const next = { ...d };
|
||||
for (const k of Object.keys(res.updated)) {
|
||||
next[k] = res.updated[k];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
pushToast("success", t("admin.settings_saved"));
|
||||
// If this group contains UI settings, refresh public UI settings so
|
||||
// the page title / favicon / logo update live.
|
||||
if (groupId === "ui") {
|
||||
void refreshUiSettings();
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed";
|
||||
const msg = err instanceof Error ? err.message : "Failed to save settings";
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,43 +211,178 @@ export function SettingsPanel() {
|
||||
return <p className="text-sm text-fg-muted">{t("common.no_data")}</p>;
|
||||
}
|
||||
|
||||
const groupOrder = ["llm", "embeddings", "qdrant", "ui", "game", "other"];
|
||||
const groupLabelKey: Record<string, string> = {
|
||||
llm: "admin.group_llm",
|
||||
embeddings: "admin.group_embeddings",
|
||||
qdrant: "admin.group_qdrant",
|
||||
ui: "admin.group_ui",
|
||||
game: "admin.group_game",
|
||||
other: "common.details",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-fg">{t("admin.tab_settings")}</h2>
|
||||
<Button onClick={handleSave} loading={saving}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
Each card saves independently. Secret values (api_key) are masked after save.
|
||||
</p>
|
||||
</div>
|
||||
{groupOrder.map((g) => {
|
||||
const entries = grouped[g];
|
||||
{GROUPS.map((g) => {
|
||||
const entries = grouped[g.id];
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<Card key={g} title={t(groupLabelKey[g] || "common.details")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{entries.map(({ key, description }) => (
|
||||
<Input
|
||||
key={key}
|
||||
label={key}
|
||||
hint={description}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.value }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<SettingsGroupCard
|
||||
key={g.id}
|
||||
title={t(g.labelKey)}
|
||||
entries={entries}
|
||||
draft={draft}
|
||||
onChange={(key, value) =>
|
||||
setDraft((d) => ({ ...d, [key]: value }))
|
||||
}
|
||||
onSave={() => void handleSaveGroup(g.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsGroupCardProps {
|
||||
title: string;
|
||||
entries: Array<{ key: string; description?: string }>;
|
||||
draft: Record<string, string>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function SettingsGroupCard({ title, entries, draft, onChange, onSave }: SettingsGroupCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Card
|
||||
title={title}
|
||||
actions={
|
||||
<Button size="sm" onClick={handleSave} loading={saving}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{entries.map(({ key, description }) => (
|
||||
<SettingField
|
||||
key={key}
|
||||
settingKey={key}
|
||||
description={description}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingFieldProps {
|
||||
settingKey: string;
|
||||
description?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function SettingField({ settingKey, description, value, onChange }: SettingFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const ft = fieldType(settingKey);
|
||||
const hint = hintFor(settingKey) || description;
|
||||
const label = settingKey;
|
||||
|
||||
if (ft === "boolean") {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="label" htmlFor={`setting-${settingKey}`}>{label}</label>
|
||||
<select
|
||||
id={`setting-${settingKey}`}
|
||||
className="input"
|
||||
value={value === "true" ? "true" : value === "false" ? "false" : value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ft === "provider") {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="label" htmlFor={`setting-${settingKey}`}>{label}</label>
|
||||
<select
|
||||
id={`setting-${settingKey}`}
|
||||
className="input"
|
||||
value={value || "offline_hash"}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="offline_hash">offline_hash</option>
|
||||
<option value="openai">openai</option>
|
||||
</select>
|
||||
{hint && <p className="mt-1 text-xs text-fg-muted">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ft === "integer") {
|
||||
return (
|
||||
<Input
|
||||
id={`setting-${settingKey}`}
|
||||
label={label}
|
||||
hint={hint}
|
||||
type="number"
|
||||
step={1}
|
||||
min={0}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (ft === "float") {
|
||||
return (
|
||||
<Input
|
||||
id={`setting-${settingKey}`}
|
||||
label={label}
|
||||
hint={hint}
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0}
|
||||
max={2}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (ft === "secret") {
|
||||
return (
|
||||
<Input
|
||||
id={`setting-${settingKey}`}
|
||||
label={label}
|
||||
hint={hint}
|
||||
type="password"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t("admin.api_key")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={`setting-${settingKey}`}
|
||||
label={label}
|
||||
hint={hint}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { AdminApi, toErrorMessage } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { AdminStats } from "@/types";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
@@ -13,11 +13,15 @@ export function StatsPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
AdminApi.stats()
|
||||
.then(setStats)
|
||||
.catch((err) => pushToast("error", err instanceof Error ? err.message : "Failed"))
|
||||
.finally(() => setLoading(false));
|
||||
.then((s) => !cancelled && setStats(s))
|
||||
.catch((err) => !cancelled && pushToast("error", toErrorMessage(err)))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pushToast]);
|
||||
|
||||
if (loading) {
|
||||
@@ -31,9 +35,14 @@ export function StatsPanel() {
|
||||
return <p className="text-sm text-fg-muted">{t("common.no_data")}</p>;
|
||||
}
|
||||
|
||||
const archived = stats.worlds_archived ?? 0;
|
||||
const total = stats.worlds_total ?? stats.worlds + archived;
|
||||
|
||||
const cards: Array<{ label: string; value: string | number }> = [
|
||||
{ label: t("admin.stats_users"), value: stats.users },
|
||||
{ label: t("admin.stats_worlds"), value: stats.worlds },
|
||||
{ label: t("admin.stats_active_worlds"), value: stats.worlds },
|
||||
{ label: t("admin.stats_archived"), value: archived },
|
||||
{ label: `${t("admin.stats_total_worlds")}`, value: total },
|
||||
{ label: t("admin.stats_steps"), value: stats.steps },
|
||||
{
|
||||
label: t("admin.stats_avg_latency"),
|
||||
@@ -42,11 +51,11 @@ export function StatsPanel() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{cards.map((c) => (
|
||||
<Card key={c.label}>
|
||||
<p className="text-xs uppercase tracking-wide text-fg-muted">{c.label}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-fg">{c.value}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-fg">{c.value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AdminApi } from "@/lib/api";
|
||||
import { AdminApi, toErrorMessage } from "@/lib/api";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type {
|
||||
AdminSettingsResponse,
|
||||
EmbeddingsProbeResult,
|
||||
EmbeddingsTestResult,
|
||||
LlmTestResult,
|
||||
@@ -14,6 +15,38 @@ import { Card } from "@/components/ui/Card";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
|
||||
const EMBEDDINGS_PROVIDERS = ["offline_hash", "openai"] as const;
|
||||
type EmbeddingsProvider = (typeof EMBEDDINGS_PROVIDERS)[number];
|
||||
|
||||
/**
|
||||
* Shared hook: fetch the current admin settings once on mount and expose the
|
||||
* values so the test cards can pre-fill their form fields with the live
|
||||
* configuration (llm.api_url, llm.api_key, llm.model, embeddings.*).
|
||||
*/
|
||||
function useSettingsDefaults(): {
|
||||
loading: boolean;
|
||||
settings: Record<string, string>;
|
||||
} {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [settings, setSettings] = useState<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
AdminApi.settings()
|
||||
.then((res: AdminSettingsResponse) => {
|
||||
if (cancelled) return;
|
||||
setSettings(res.settings || {});
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore — defaults remain empty strings */
|
||||
})
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return { loading, settings };
|
||||
}
|
||||
|
||||
export function TestButtons() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -30,25 +63,32 @@ export function TestButtons() {
|
||||
function LlmTestCard() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const { loading, settings } = useSettingsDefaults();
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading2, setLoading2] = useState(false);
|
||||
const [result, setResult] = useState<LlmTestResult | null>(null);
|
||||
|
||||
// Once defaults load, populate any empty fields.
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
setApiUrl((cur) => cur || settings["llm.api_url"] || "");
|
||||
setApiKey((cur) => cur || settings["llm.api_key"] || "");
|
||||
setModel((cur) => cur || settings["llm.model"] || "");
|
||||
}, [loading, settings]);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setLoading2(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await AdminApi.testLlm(apiUrl, apiKey, model);
|
||||
setResult(r);
|
||||
if (!r.ok) pushToast("error", t("admin.failed"));
|
||||
else pushToast("success", t("admin.ok"));
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : extractErr(r));
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed";
|
||||
pushToast("error", msg);
|
||||
pushToast("error", toErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading2(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,10 +100,10 @@ function LlmTestCard() {
|
||||
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} placeholder="gpt-4o-mini" />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
|
||||
{loading ? t("admin.running") : t("admin.run_test")}
|
||||
<Button onClick={run} loading={loading2} disabled={!apiUrl || !model}>
|
||||
{loading2 ? t("admin.running") : t("admin.run_test")}
|
||||
</Button>
|
||||
{loading && <Spinner size="sm" />}
|
||||
{loading2 && <Spinner size="sm" />}
|
||||
</div>
|
||||
{result && <TestResultCard result={result} />}
|
||||
</Card>
|
||||
@@ -73,14 +113,22 @@ function LlmTestCard() {
|
||||
function LlmToolsTestCard() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const { loading, settings } = useSettingsDefaults();
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading2, setLoading2] = useState(false);
|
||||
const [result, setResult] = useState<LlmToolsTestResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
setApiUrl((cur) => cur || settings["llm.api_url"] || "");
|
||||
setApiKey((cur) => cur || settings["llm.api_key"] || "");
|
||||
setModel((cur) => cur || settings["llm.model"] || "");
|
||||
}, [loading, settings]);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setLoading2(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await AdminApi.testLlmTools({
|
||||
@@ -89,11 +137,11 @@ function LlmToolsTestCard() {
|
||||
model,
|
||||
});
|
||||
setResult(r);
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed"));
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : extractErr(r));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading2(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -105,8 +153,8 @@ function LlmToolsTestCard() {
|
||||
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
|
||||
{loading ? t("admin.running") : t("admin.run_test")}
|
||||
<Button onClick={run} loading={loading2} disabled={!apiUrl || !model}>
|
||||
{loading2 ? t("admin.running") : t("admin.run_test")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && <TestResultCard result={result} />}
|
||||
@@ -117,15 +165,26 @@ function LlmToolsTestCard() {
|
||||
function EmbeddingsTestCard() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const { loading, settings } = useSettingsDefaults();
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [provider, setProvider] = useState<EmbeddingsProvider>("offline_hash");
|
||||
const [loading2, setLoading2] = useState(false);
|
||||
const [result, setResult] = useState<EmbeddingsTestResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
setApiUrl((cur) => cur || settings["embeddings.api_url"] || "");
|
||||
setApiKey((cur) => cur || settings["embeddings.api_key"] || "");
|
||||
setModel((cur) => cur || settings["embeddings.model"] || "");
|
||||
const p = settings["embeddings.provider"];
|
||||
if (p === "openai" || p === "offline_hash") setProvider(p);
|
||||
else setProvider("offline_hash");
|
||||
}, [loading, settings]);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setLoading2(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await AdminApi.testEmbeddings({
|
||||
@@ -135,11 +194,11 @@ function EmbeddingsTestCard() {
|
||||
provider,
|
||||
});
|
||||
setResult(r);
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed"));
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : extractErr(r));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading2(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,11 +208,26 @@ function EmbeddingsTestCard() {
|
||||
<Input label={t("admin.api_url")} value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
|
||||
<Input label={t("admin.api_key")} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
||||
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
<Input label={t("admin.provider")} value={provider} onChange={(e) => setProvider(e.target.value)} placeholder="openai" />
|
||||
<div className="w-full">
|
||||
<label className="label" htmlFor="emb-test-provider">{t("admin.provider")}</label>
|
||||
<select
|
||||
id="emb-test-provider"
|
||||
className="input"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value as EmbeddingsProvider)}
|
||||
>
|
||||
{EMBEDDINGS_PROVIDERS.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
If provider=openai and api_url is empty, the system falls back to llm.api_url
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
|
||||
{loading ? t("admin.running") : t("admin.run_test")}
|
||||
<Button onClick={run} loading={loading2}>
|
||||
{loading2 ? t("admin.running") : t("admin.run_test")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && <TestResultCard result={result} />}
|
||||
@@ -164,15 +238,26 @@ function EmbeddingsTestCard() {
|
||||
function ProbeDimensionCard() {
|
||||
const { t } = useTranslation();
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const { loading, settings } = useSettingsDefaults();
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [provider, setProvider] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [provider, setProvider] = useState<EmbeddingsProvider>("offline_hash");
|
||||
const [loading2, setLoading2] = useState(false);
|
||||
const [result, setResult] = useState<EmbeddingsProbeResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
setApiUrl((cur) => cur || settings["embeddings.api_url"] || "");
|
||||
setApiKey((cur) => cur || settings["embeddings.api_key"] || "");
|
||||
setModel((cur) => cur || settings["embeddings.model"] || "");
|
||||
const p = settings["embeddings.provider"];
|
||||
if (p === "openai" || p === "offline_hash") setProvider(p);
|
||||
else setProvider("offline_hash");
|
||||
}, [loading, settings]);
|
||||
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
setLoading2(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await AdminApi.probeDimension({
|
||||
@@ -182,11 +267,11 @@ function ProbeDimensionCard() {
|
||||
provider,
|
||||
});
|
||||
setResult(r);
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : t("admin.failed"));
|
||||
pushToast(r.ok ? "success" : "error", r.ok ? t("admin.ok") : extractErr(r));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading2(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -196,11 +281,23 @@ function ProbeDimensionCard() {
|
||||
<Input label={t("admin.api_url")} value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} />
|
||||
<Input label={t("admin.api_key")} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
||||
<Input label={t("admin.model")} value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
<Input label={t("admin.provider")} value={provider} onChange={(e) => setProvider(e.target.value)} />
|
||||
<div className="w-full">
|
||||
<label className="label" htmlFor="emb-probe-provider">{t("admin.provider")}</label>
|
||||
<select
|
||||
id="emb-probe-provider"
|
||||
className="input"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value as EmbeddingsProvider)}
|
||||
>
|
||||
{EMBEDDINGS_PROVIDERS.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={run} loading={loading} disabled={!apiUrl || !model}>
|
||||
{loading ? t("admin.running") : t("admin.run_test")}
|
||||
<Button onClick={run} loading={loading2}>
|
||||
{loading2 ? t("admin.running") : t("admin.run_test")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && <TestResultCard result={result} />}
|
||||
@@ -222,7 +319,7 @@ function RecreateCollectionsCard() {
|
||||
setResult(r);
|
||||
pushToast("success", `${t("admin.ok")}: dropped=${r.dropped} created=${r.created} dim=${r.dimension}`);
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -286,6 +383,11 @@ function TestResultCard({ result }: { result: Record<string, unknown> }) {
|
||||
{result.error}
|
||||
</pre>
|
||||
)}
|
||||
{result.error != null && typeof result.error === "object" && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded bg-bg-soft p-2 text-err">
|
||||
{JSON.stringify(result.error, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{Array.isArray(result.first_5_values) && (
|
||||
<p className="mt-1 text-fg-muted font-mono">
|
||||
first_5_values: [{(result.first_5_values as number[]).slice(0, 5).map((v) => typeof v === "number" ? v.toFixed(4) : String(v)).join(", ")}]
|
||||
@@ -299,3 +401,14 @@ function TestResultCard({ result }: { result: Record<string, unknown> }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract a human-readable error message from a test result object. */
|
||||
function extractErr(result: Record<string, unknown>): string {
|
||||
const e = result.error;
|
||||
if (typeof e === "string") return e;
|
||||
if (e && typeof e === "object") {
|
||||
const obj = e as { message?: string; code?: string };
|
||||
return obj.message || obj.code || JSON.stringify(e);
|
||||
}
|
||||
return "Failed";
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useUiSettingsStore } from "@/stores/uiSettingsStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { Button } from "./Button";
|
||||
|
||||
@@ -18,6 +19,7 @@ export function Navbar() {
|
||||
const language = useUiStore((s) => s.language);
|
||||
const setLanguage = useUiStore((s) => s.setLanguage);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
const logoUrl = useUiSettingsStore((s) => s.settings?.logo_url);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -49,7 +51,16 @@ export function Navbar() {
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to={user ? "/worlds" : "/login"} className="flex items-center gap-2">
|
||||
<span className="text-lg">🎲</span>
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={t("common.app_name")}
|
||||
className="h-7 w-7 rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-lg">🎲</span>
|
||||
)}
|
||||
<span className="font-semibold text-fg">{t("common.app_name")}</span>
|
||||
</Link>
|
||||
{user && (
|
||||
@@ -57,9 +68,6 @@ export function Navbar() {
|
||||
<NavLink to="/worlds" active={location.pathname === "/worlds" || location.pathname === "/"}>
|
||||
{t("nav.worlds")}
|
||||
</NavLink>
|
||||
<NavLink to="/worlds/new" active={location.pathname === "/worlds/new"}>
|
||||
{t("nav.create_world")}
|
||||
</NavLink>
|
||||
{user.is_admin && (
|
||||
<NavLink to="/admin" active={location.pathname.startsWith("/admin")}>
|
||||
{t("nav.admin")}
|
||||
|
||||
@@ -246,9 +246,22 @@ export function WorldBuilder({ className }: WorldBuilderProps) {
|
||||
}
|
||||
const res = await WorldsApi.create(payload);
|
||||
const streamUrl = SessionsApi.builderStreamUrl(res.world_id);
|
||||
let connectionLostToastShown = false;
|
||||
const c = subscribeSse(streamUrl, {
|
||||
onOpen: () => setState((s) => ({ ...s, sseStatus: "open" })),
|
||||
onError: () => setState((s) => ({ ...s, sseStatus: "error" })),
|
||||
onOpen: () => {
|
||||
connectionLostToastShown = false;
|
||||
setState((s) => ({ ...s, sseStatus: "open" }));
|
||||
},
|
||||
onError: () => {
|
||||
setState((s) => ({ ...s, sseStatus: "error" }));
|
||||
// The SSE client auto-reconnects with exponential backoff. Surface
|
||||
// a single "Connection lost" toast so the user knows what's
|
||||
// happening without spamming on every reconnect attempt.
|
||||
if (!connectionLostToastShown) {
|
||||
connectionLostToastShown = true;
|
||||
pushToast("warning", t("sse.reconnecting"));
|
||||
}
|
||||
},
|
||||
onClose: () => setState((s) => ({ ...s, sseStatus: "closed" })),
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { AdminApi, WorldsApi, toErrorMessage } from "@/lib/api";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { WorldListItem, WorldStatus } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
export interface WorldCardProps {
|
||||
world: WorldListItem;
|
||||
onDelete?: (world: WorldListItem) => void;
|
||||
onRestored?: (world: WorldListItem) => void;
|
||||
onPermanentlyDeleted?: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -26,11 +32,47 @@ const STATUS_LABEL_KEY: Record<WorldStatus, string> = {
|
||||
archived: "worlds.status_archived",
|
||||
};
|
||||
|
||||
export function WorldCard({ world, onDelete, className }: WorldCardProps) {
|
||||
export function WorldCard({ world, onDelete, onRestored, onPermanentlyDeleted, className }: WorldCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [hardDeleting, setHardDeleting] = useState(false);
|
||||
|
||||
const isReady = world.status === "ready";
|
||||
const isArchived = world.status === "archived";
|
||||
const isAdmin = !!user?.is_admin;
|
||||
|
||||
const handleRestore = async () => {
|
||||
setRestoring(true);
|
||||
try {
|
||||
await WorldsApi.update(world.id, { status: "ready" });
|
||||
pushToast("success", t("worlds.restored"));
|
||||
onRestored?.({ ...world, status: "ready" });
|
||||
} catch (err) {
|
||||
pushToast("error", toErrorMessage(err, t("worlds.restore_failed")));
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHardDelete = async () => {
|
||||
if (!isAdmin) return;
|
||||
const confirmed = window.confirm(t("worlds.delete_permanent_confirm"));
|
||||
if (!confirmed) return;
|
||||
setHardDeleting(true);
|
||||
try {
|
||||
await AdminApi.hardDeleteWorld(world.id);
|
||||
pushToast("success", t("worlds.deleted"));
|
||||
onPermanentlyDeleted?.(world.id);
|
||||
} catch (err) {
|
||||
pushToast("error", toErrorMessage(err, t("worlds.delete_failed")));
|
||||
} finally {
|
||||
setHardDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -68,32 +110,60 @@ export function WorldCard({ world, onDelete, className }: WorldCardProps) {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<footer className="mt-2 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isReady ? "primary" : "secondary"}
|
||||
onClick={() => navigate(`/worlds/${world.id}/play`)}
|
||||
disabled={!isReady}
|
||||
fullWidth
|
||||
>
|
||||
{t("worlds.play")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/worlds/${world.id}/edit`)}
|
||||
>
|
||||
{t("worlds.edit")}
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(world)}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
🗑
|
||||
</Button>
|
||||
<footer className="mt-2 flex flex-wrap gap-2">
|
||||
{isArchived ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleRestore}
|
||||
loading={restoring}
|
||||
disabled={restoring}
|
||||
fullWidth
|
||||
>
|
||||
{t("worlds.restore")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={handleHardDelete}
|
||||
loading={hardDeleting}
|
||||
disabled={hardDeleting}
|
||||
>
|
||||
{t("worlds.delete_permanent")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isReady ? "primary" : "secondary"}
|
||||
onClick={() => navigate(`/worlds/${world.id}/play`)}
|
||||
disabled={!isReady}
|
||||
fullWidth
|
||||
>
|
||||
{t("worlds.play")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/worlds/${world.id}/edit`)}
|
||||
>
|
||||
{t("worlds.edit")}
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(world)}
|
||||
aria-label={t("common.delete")}
|
||||
>
|
||||
🗑
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
@@ -146,9 +146,19 @@ export function WorldEditor({ world, onWorldUpdated, className }: WorldEditorPro
|
||||
try {
|
||||
await WorldsApi.edit(world.id, { instruction: text });
|
||||
const url = SessionsApi.editorStreamUrl(world.id, text);
|
||||
let connectionLostToastShown = false;
|
||||
const c = subscribeSse(url, {
|
||||
onOpen: () => setSseStatus("open"),
|
||||
onError: () => setSseStatus("error"),
|
||||
onOpen: () => {
|
||||
connectionLostToastShown = false;
|
||||
setSseStatus("open");
|
||||
},
|
||||
onError: () => {
|
||||
setSseStatus("error");
|
||||
if (!connectionLostToastShown) {
|
||||
connectionLostToastShown = true;
|
||||
pushToast("warning", t("sse.reconnecting"));
|
||||
}
|
||||
},
|
||||
onClose: () => setSseStatus("closed"),
|
||||
onEvent: handleEvent,
|
||||
});
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
"admin_register_success": "Admin account created. You can sign in now.",
|
||||
"login_failed": "Sign-in failed",
|
||||
"register_failed": "Registration failed",
|
||||
"session_expired": "Session expired, please sign in again."
|
||||
"session_expired": "Session expired, please sign in again.",
|
||||
"username_hint": "Letters, numbers, and underscores only. No @ or other special characters.",
|
||||
"username_invalid_chars": "Username can only contain letters, numbers, and underscores (a-z, A-Z, 0-9, _). @ and other special characters are not allowed."
|
||||
},
|
||||
"worlds": {
|
||||
"title": "Your Worlds",
|
||||
@@ -85,7 +87,12 @@
|
||||
"status_building": "Building",
|
||||
"status_ready": "Ready",
|
||||
"status_failed": "Failed",
|
||||
"status_archived": "Archived"
|
||||
"status_archived": "Archived",
|
||||
"restore": "Restore",
|
||||
"restored": "World restored.",
|
||||
"restore_failed": "Failed to restore world.",
|
||||
"delete_permanent": "Delete permanently",
|
||||
"delete_permanent_confirm": "Permanently delete this world? This action cannot be undone and removes all steps, entities, and logs."
|
||||
},
|
||||
"builder": {
|
||||
"title": "World Builder",
|
||||
@@ -164,6 +171,9 @@
|
||||
"rollback": "Rollback one step",
|
||||
"rollback_confirm": "Rollback the last step?",
|
||||
"rolled_back": "Rolled back one step.",
|
||||
"not_ready": "World is not ready yet. Complete world creation first.",
|
||||
"no_steps_to_retry": "No steps to retry yet.",
|
||||
"no_steps_to_rollback": "No steps to rollback yet.",
|
||||
"streaming": "AI is responding…",
|
||||
"scene_chunk": "Scene",
|
||||
"iteration_complete": "Step complete",
|
||||
@@ -191,11 +201,12 @@
|
||||
"settings_saved": "Settings saved.",
|
||||
"settings_save_failed": "Failed to save settings.",
|
||||
"settings_load_failed": "Failed to load settings.",
|
||||
"group_llm": "LLM",
|
||||
"group_llm": "LLM Configuration",
|
||||
"group_embeddings": "Embeddings",
|
||||
"group_qdrant": "Qdrant",
|
||||
"group_ui": "UI",
|
||||
"group_game": "Game",
|
||||
"group_context": "Context Manager",
|
||||
"group_ui": "UI Settings",
|
||||
"group_game": "Game Settings",
|
||||
"logs_filter_world": "World ID",
|
||||
"logs_filter_stage": "Stage",
|
||||
"logs_filter_status": "Status",
|
||||
@@ -221,6 +232,9 @@
|
||||
"users_deactivate": "Deactivate",
|
||||
"stats_users": "Users",
|
||||
"stats_worlds": "Worlds",
|
||||
"stats_active_worlds": "Active Worlds",
|
||||
"stats_archived": "Archived",
|
||||
"stats_total_worlds": "Total Worlds",
|
||||
"stats_steps": "Steps",
|
||||
"stats_avg_latency": "Avg LLM latency",
|
||||
"test_llm": "Test LLM",
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
"admin_register_success": "Аккаунт администратора создан. Теперь можно войти.",
|
||||
"login_failed": "Не удалось войти",
|
||||
"register_failed": "Не удалось зарегистрироваться",
|
||||
"session_expired": "Сессия истекла, пожалуйста, войдите снова."
|
||||
"session_expired": "Сессия истекла, пожалуйста, войдите снова.",
|
||||
"username_hint": "Только буквы, цифры и подчёркивание. Без @ и других спецсимволов.",
|
||||
"username_invalid_chars": "Имя пользователя может содержать только буквы, цифры и подчёркивание (a-z, A-Z, 0-9, _). @ и другие спецсимволы не допускаются."
|
||||
},
|
||||
"worlds": {
|
||||
"title": "Ваши миры",
|
||||
@@ -85,7 +87,12 @@
|
||||
"status_building": "Создаётся",
|
||||
"status_ready": "Готов",
|
||||
"status_failed": "Ошибка",
|
||||
"status_archived": "В архиве"
|
||||
"status_archived": "В архиве",
|
||||
"restore": "Восстановить",
|
||||
"restored": "Мир восстановлен.",
|
||||
"restore_failed": "Не удалось восстановить мир.",
|
||||
"delete_permanent": "Удалить навсегда",
|
||||
"delete_permanent_confirm": "Навсегда удалить этот мир? Действие необратимо и удалит все шаги, сущности и логи."
|
||||
},
|
||||
"builder": {
|
||||
"title": "Создание мира",
|
||||
@@ -164,6 +171,9 @@
|
||||
"rollback": "Откатить один шаг",
|
||||
"rollback_confirm": "Откатить последний шаг?",
|
||||
"rolled_back": "Шаг откатан.",
|
||||
"not_ready": "Мир ещё не готов. Сначала завершите создание мира.",
|
||||
"no_steps_to_retry": "Нет шагов для повтора.",
|
||||
"no_steps_to_rollback": "Нет шагов для отката.",
|
||||
"streaming": "AI отвечает…",
|
||||
"scene_chunk": "Сцена",
|
||||
"iteration_complete": "Шаг завершён",
|
||||
@@ -191,11 +201,12 @@
|
||||
"settings_saved": "Настройки сохранены.",
|
||||
"settings_save_failed": "Не удалось сохранить настройки.",
|
||||
"settings_load_failed": "Не удалось загрузить настройки.",
|
||||
"group_llm": "LLM",
|
||||
"group_llm": "Конфигурация LLM",
|
||||
"group_embeddings": "Эмбеддинги",
|
||||
"group_qdrant": "Qdrant",
|
||||
"group_ui": "Интерфейс",
|
||||
"group_game": "Игра",
|
||||
"group_context": "Менеджер контекста",
|
||||
"group_ui": "Настройки интерфейса",
|
||||
"group_game": "Игровые настройки",
|
||||
"logs_filter_world": "ID мира",
|
||||
"logs_filter_stage": "Стадия",
|
||||
"logs_filter_status": "Статус",
|
||||
@@ -221,6 +232,9 @@
|
||||
"users_deactivate": "Деактивировать",
|
||||
"stats_users": "Пользователи",
|
||||
"stats_worlds": "Миры",
|
||||
"stats_active_worlds": "Активные миры",
|
||||
"stats_archived": "В архиве",
|
||||
"stats_total_worlds": "Всего миров",
|
||||
"stats_steps": "Шаги",
|
||||
"stats_avg_latency": "Средняя задержка LLM",
|
||||
"test_llm": "Тест LLM",
|
||||
|
||||
@@ -4,15 +4,41 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Dark theme (default) — RGB triplets so Tailwind opacity modifiers work. */
|
||||
--bg: 15 17 21; /* #0f1115 */
|
||||
--bg-soft: 23 26 33; /* #171a21 */
|
||||
--bg-card: 30 35 44; /* #1e232c */
|
||||
--fg: 230 233 239; /* #e6e9ef */
|
||||
--fg-muted: 156 163 175; /* #9ca3af */
|
||||
--fg-dim: 107 114 128; /* #6b7280 */
|
||||
--accent: 139 92 246; /* #8b5cf6 */
|
||||
--accent-hover: 124 58 237;/* #7c3aed */
|
||||
--ok: 16 185 129; /* #10b981 */
|
||||
--warn: 245 158 11; /* #f59e0b */
|
||||
--err: 239 68 68; /* #ef4444 */
|
||||
color-scheme: dark;
|
||||
}
|
||||
html {
|
||||
@apply bg-bg text-fg antialiased;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
html:not(.dark) {
|
||||
/* Light theme — high-contrast, readable. */
|
||||
--bg: 250 250 250; /* #fafafa */
|
||||
--bg-soft: 241 241 244; /* #f1f1f4 */
|
||||
--bg-card: 255 255 255; /* #ffffff */
|
||||
--fg: 26 26 26; /* #1a1a1a */
|
||||
--fg-muted: 75 85 99; /* #4b5563 */
|
||||
--fg-dim: 107 114 128; /* #6b7280 */
|
||||
--accent: 124 58 237; /* #7c3aed */
|
||||
--accent-hover: 109 40 217;/* #6d28d9 */
|
||||
--ok: 5 150 105; /* #059669 */
|
||||
--warn: 217 119 6; /* #d97706 */
|
||||
--err: 220 38 38; /* #dc2626 */
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply antialiased;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply min-h-screen bg-bg text-fg;
|
||||
}
|
||||
@@ -61,7 +87,7 @@
|
||||
content: "▋";
|
||||
margin-left: 1px;
|
||||
animation: blink 1s steps(2) infinite;
|
||||
color: var(--tw-prose-invert-colors, #8b5cf6);
|
||||
color: rgb(var(--accent));
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
@@ -73,14 +99,3 @@
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Light mode overrides */
|
||||
html:not(.dark) body {
|
||||
@apply bg-gray-100 text-gray-900;
|
||||
}
|
||||
html:not(.dark) .card {
|
||||
@apply bg-white border-gray-200;
|
||||
}
|
||||
html:not(.dark) .input {
|
||||
@apply bg-white border-gray-300 text-gray-900;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
LoginPayload,
|
||||
Paginated,
|
||||
PresetListItem,
|
||||
PublicSettings,
|
||||
RecreateCollectionsResult,
|
||||
RegisterPayload,
|
||||
RetryResponse,
|
||||
@@ -47,6 +48,93 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a FastAPI/Starlette error `detail` field (which may be a string,
|
||||
* an array of validation objects, or a nested object) into a single
|
||||
* human-readable string. Avoids the dreaded "[object Object]" toast.
|
||||
*/
|
||||
export function formatApiErrorDetail(detail: unknown): string | null {
|
||||
if (detail == null) return null;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
const parts: string[] = [];
|
||||
for (const item of detail) {
|
||||
if (typeof item === "string") {
|
||||
parts.push(item);
|
||||
continue;
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
const obj = item as {
|
||||
msg?: string;
|
||||
message?: string;
|
||||
loc?: unknown;
|
||||
type?: string;
|
||||
ctx?: unknown;
|
||||
};
|
||||
const loc = Array.isArray(obj.loc)
|
||||
? obj.loc.filter((x) => typeof x === "string" || typeof x === "number").join(".")
|
||||
: obj.loc != null
|
||||
? String(obj.loc)
|
||||
: "";
|
||||
const msg = obj.msg || obj.message || "";
|
||||
const ctxStr =
|
||||
obj.ctx && typeof obj.ctx === "object" ? JSON.stringify(obj.ctx) : "";
|
||||
const text = [loc, msg, ctxStr].filter(Boolean).join(": ");
|
||||
parts.push(text || JSON.stringify(item));
|
||||
continue;
|
||||
}
|
||||
parts.push(JSON.stringify(item));
|
||||
}
|
||||
return parts.filter(Boolean).join("; ");
|
||||
}
|
||||
if (typeof detail === "object") {
|
||||
const obj = detail as { message?: string; detail?: unknown; error?: string };
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.error === "string") return obj.error;
|
||||
const nested = formatApiErrorDetail(obj.detail);
|
||||
if (nested) return nested;
|
||||
try {
|
||||
return JSON.stringify(detail);
|
||||
} catch {
|
||||
return String(detail);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any thrown value into a human-readable string suitable for display
|
||||
* in toasts or inline error messages. Never returns "[object Object]".
|
||||
*/
|
||||
export function toErrorMessage(err: unknown, fallback = "Something went wrong"): string {
|
||||
if (err == null) return fallback;
|
||||
if (typeof err === "string") return err;
|
||||
if (err instanceof ApiError) {
|
||||
return err.message || fallback;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return err.message || fallback;
|
||||
}
|
||||
if (typeof err === "object") {
|
||||
const obj = err as {
|
||||
message?: unknown;
|
||||
detail?: unknown;
|
||||
error?: unknown;
|
||||
status?: unknown;
|
||||
};
|
||||
const detail = formatApiErrorDetail(obj.detail);
|
||||
if (detail) return detail;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.error === "string") return obj.error;
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
return String(err);
|
||||
}
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
@@ -174,13 +262,13 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
unauthorizedHandler?.();
|
||||
const data = (await parseResponse<{ detail?: string }>(res).catch(() => ({}))) as { detail?: string };
|
||||
throw new ApiError(401, data?.detail || "Unauthorized");
|
||||
const data = (await parseResponse<{ detail?: unknown }>(res).catch(() => ({}))) as { detail?: unknown };
|
||||
throw new ApiError(401, formatApiErrorDetail(data?.detail) || "Unauthorized", data);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await parseResponse<{ detail?: string; message?: string }>(res).catch(() => ({}))) as { detail?: string; message?: string };
|
||||
const message = data?.detail || data?.message || `Request failed (${res.status})`;
|
||||
const data = (await parseResponse<{ detail?: unknown; message?: string }>(res).catch(() => ({}))) as { detail?: unknown; message?: string };
|
||||
const message = formatApiErrorDetail(data?.detail) || data?.message || `Request failed (${res.status})`;
|
||||
throw new ApiError(res.status, message, data);
|
||||
}
|
||||
|
||||
@@ -267,7 +355,7 @@ export type LlmLogsQuery = {
|
||||
export const AdminApi = {
|
||||
settings: () => request<AdminSettingsResponse>("/admin/settings"),
|
||||
updateSettings: (settings: Record<string, string>) =>
|
||||
request<AdminSettingsResponse>("/admin/settings", { method: "PATCH", body: { settings } }),
|
||||
request<{ updated: Record<string, string> }>("/admin/settings", { method: "PATCH", body: { settings } }),
|
||||
llmLogs: (query: LlmLogsQuery = {}) =>
|
||||
request<Paginated<LlmLog>>("/admin/llm-logs", { query: { ...query } }),
|
||||
llmLog: (id: string) => request<LlmLogDetail>(`/admin/llm-logs/${id}`),
|
||||
@@ -297,8 +385,11 @@ export const AdminApi = {
|
||||
fd.append("kind", kind);
|
||||
return request<UploadIconResult>("/admin/upload-icon", { method: "POST", formData: fd });
|
||||
},
|
||||
hardDeleteWorld: (id: string) =>
|
||||
request<{ ok: boolean; deleted: string }>(`/admin/worlds/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
export const MiscApi = {
|
||||
health: () => request<HealthResponse>("/health"),
|
||||
publicSettings: () => request<PublicSettings>("/settings/public"),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSessionStore } from "@/stores/sessionStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { toErrorMessage } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
@@ -20,6 +21,7 @@ export function PlayPage() {
|
||||
const world = useSessionStore((s) => s.world);
|
||||
const environment = useSessionStore((s) => s.environment);
|
||||
const nextActions = useSessionStore((s) => s.nextActions);
|
||||
const recentSteps = useSessionStore((s) => s.recentSteps);
|
||||
const loading = useSessionStore((s) => s.loading);
|
||||
const error = useSessionStore((s) => s.error);
|
||||
const submitting = useSessionStore((s) => s.submitting);
|
||||
@@ -31,15 +33,26 @@ export function PlayPage() {
|
||||
const reset = useSessionStore((s) => s.reset);
|
||||
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
void fetchState(id).catch(() => pushToast("error", t("play.load_failed")));
|
||||
void fetchState(id).catch((err) => pushToast("error", toErrorMessage(err, t("play.load_failed"))));
|
||||
return () => {
|
||||
reset();
|
||||
};
|
||||
}, [id, fetchState, reset, pushToast, t]);
|
||||
|
||||
// Redirect non-ready worlds to the edit page (once we have the world loaded).
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (world && world.status !== "ready" && !redirected) {
|
||||
pushToast("warning", t("play.not_ready"));
|
||||
setRedirected(true);
|
||||
navigate(`/worlds/${id}/edit`, { replace: true });
|
||||
}
|
||||
}, [id, world, redirected, navigate, pushToast, t]);
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
if (loading && !world) {
|
||||
@@ -60,25 +73,27 @@ export function PlayPage() {
|
||||
);
|
||||
}
|
||||
if (!world) return null;
|
||||
if (world.status !== "ready") return null;
|
||||
|
||||
const player = environment?.player;
|
||||
const plotRails: PlotRail[] = Array.isArray(world.plot_rails) ? world.plot_rails : [];
|
||||
const noSteps = recentSteps.length === 0;
|
||||
|
||||
const handleSend = (action: string) => {
|
||||
void sendAction(id, action, "manual").catch((err) => {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err, "Failed"));
|
||||
});
|
||||
};
|
||||
|
||||
const handleSuggested = (action: string) => {
|
||||
void sendAction(id, action, "suggested").catch((err) => {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err, "Failed"));
|
||||
});
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
void retry(id).catch((err) => {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err, "Failed"));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -88,7 +103,7 @@ export function PlayPage() {
|
||||
await rollback(id);
|
||||
pushToast("success", t("play.rolled_back"));
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : "Failed");
|
||||
pushToast("error", toErrorMessage(err, "Failed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,14 +175,23 @@ export function PlayPage() {
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleRetry} disabled={submitting}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleRetry}
|
||||
disabled={submitting || noSteps}
|
||||
className={noSteps ? "opacity-50 cursor-not-allowed" : ""}
|
||||
title={noSteps ? t("play.no_steps_to_retry") : undefined}
|
||||
>
|
||||
{t("play.retry_last")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setRollbackOpen(true)}
|
||||
disabled={submitting}
|
||||
disabled={submitting || noSteps}
|
||||
className={noSteps ? "opacity-50 cursor-not-allowed" : ""}
|
||||
title={noSteps ? t("play.no_steps_to_rollback") : undefined}
|
||||
>
|
||||
{t("play.rollback")}
|
||||
</Button>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { useNavigate, Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { ApiError, toErrorMessage } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
const USERNAME_INVALID_CHARS_RE = /[^a-zA-Z0-9_]/;
|
||||
|
||||
export function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -25,6 +27,9 @@ export function RegisterPage() {
|
||||
const next: Record<string, string> = {};
|
||||
if (!email.includes("@")) next.email = t("errors.validation");
|
||||
if (username.trim().length < 3) next.username = t("errors.validation");
|
||||
if (USERNAME_INVALID_CHARS_RE.test(username.trim())) {
|
||||
next.username = t("auth.username_invalid_chars");
|
||||
}
|
||||
if (password.length < 8) next.password = t("errors.validation");
|
||||
if (password !== passwordConfirm) next.password_confirm = t("errors.validation");
|
||||
setErrors(next);
|
||||
@@ -45,8 +50,42 @@ export function RegisterPage() {
|
||||
pushToast("success", t("auth.register_success"));
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : t("auth.register_failed");
|
||||
pushToast("error", msg);
|
||||
if (err instanceof ApiError && err.status === 422) {
|
||||
// FastAPI 422 validation error — extract field-level messages.
|
||||
const detail = err.details;
|
||||
const fieldMsgs: Record<string, string> = {};
|
||||
let generalMsg = "";
|
||||
if (Array.isArray(detail)) {
|
||||
for (const item of detail) {
|
||||
if (item && typeof item === "object") {
|
||||
const obj = item as { loc?: unknown; msg?: string; message?: string };
|
||||
const loc = Array.isArray(obj.loc)
|
||||
? obj.loc.map((x) => String(x)).filter((x) => x !== "body" && x !== "query").join(".")
|
||||
: obj.loc != null
|
||||
? String(obj.loc)
|
||||
: "";
|
||||
const msg = obj.msg || obj.message || "";
|
||||
if (loc) {
|
||||
fieldMsgs[loc] = msg || t("errors.validation");
|
||||
} else {
|
||||
generalMsg = generalMsg ? `${generalMsg}; ${msg}` : msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we have specific field messages, show them inline.
|
||||
if (Object.keys(fieldMsgs).length > 0) {
|
||||
setErrors((prev) => ({ ...prev, ...fieldMsgs }));
|
||||
}
|
||||
// If the username field failed validation, show the specific message.
|
||||
if (fieldMsgs.username || fieldMsgs["username"]) {
|
||||
pushToast("error", t("auth.username_invalid_chars"));
|
||||
} else {
|
||||
pushToast("error", generalMsg || t("auth.register_failed"));
|
||||
}
|
||||
} else {
|
||||
pushToast("error", toErrorMessage(err, t("auth.register_failed")));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -72,6 +111,7 @@ export function RegisterPage() {
|
||||
autoComplete="username"
|
||||
required
|
||||
error={errors.username}
|
||||
hint={t("auth.username_hint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWorldsStore } from "@/stores/worldsStore";
|
||||
@@ -27,6 +27,10 @@ export function WorldsListPage() {
|
||||
void fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
// Client-side filter: never show archived worlds (the backend already
|
||||
// excludes them by default, but this is a safety net).
|
||||
const visibleList = useMemo(() => list.filter((w) => w.status !== "archived"), [list]);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!toDelete) return;
|
||||
setDeleting(true);
|
||||
@@ -48,7 +52,7 @@ export function WorldsListPage() {
|
||||
<Button onClick={() => navigate("/worlds/new")}>{t("worlds.create_new")}</Button>
|
||||
</header>
|
||||
|
||||
{loading && list.length === 0 ? (
|
||||
{loading && visibleList.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
@@ -59,7 +63,7 @@ export function WorldsListPage() {
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</Card>
|
||||
) : list.length === 0 ? (
|
||||
) : visibleList.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-fg-muted">{t("worlds.empty")}</p>
|
||||
<Button className="mt-3" onClick={() => navigate("/worlds/new")}>
|
||||
@@ -68,7 +72,7 @@ export function WorldsListPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{list.map((w) => (
|
||||
{visibleList.map((w) => (
|
||||
<WorldCard key={w.id} world={w} onDelete={setToDelete} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import { SessionsApi } from "@/lib/api";
|
||||
import { SessionsApi, toErrorMessage } from "@/lib/api";
|
||||
import { subscribeSse, type SseController, type SseEvent } from "@/lib/sse";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type {
|
||||
Environment,
|
||||
SessionState,
|
||||
@@ -83,8 +84,11 @@ function handleEvent(state: SessionStateStore, event: SseEvent, worldId: string)
|
||||
break;
|
||||
case "error": {
|
||||
const data = event.data as { message?: string; code?: string };
|
||||
pushMessage({ id: uid(), kind: "error", message: data?.message || "Stream error" });
|
||||
const msg = typeof data?.message === "string" ? data.message : "Stream error";
|
||||
pushMessage({ id: uid(), kind: "error", message: msg });
|
||||
patch({ sseStatus: "error" });
|
||||
// Surface the error as a toast so it's never silently swallowed.
|
||||
useToastStore.getState().push("error", msg);
|
||||
break;
|
||||
}
|
||||
case "warning": {
|
||||
@@ -226,7 +230,7 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
||||
} catch (err) {
|
||||
set({
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "Failed to load session",
|
||||
error: toErrorMessage(err, "Failed to load session"),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -238,10 +242,9 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
||||
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
||||
get().subscribeIterate(worldId, res.step_id);
|
||||
} catch (err) {
|
||||
set({
|
||||
submitting: false,
|
||||
error: err instanceof Error ? err.message : "Failed to send action",
|
||||
});
|
||||
const msg = toErrorMessage(err, "Failed to send action");
|
||||
set({ submitting: false, error: msg });
|
||||
useToastStore.getState().push("error", msg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -253,10 +256,9 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
||||
set({ streamingStepId: res.step_id, sseStatus: "connecting" });
|
||||
get().subscribeIterate(worldId, res.step_id);
|
||||
} catch (err) {
|
||||
set({
|
||||
submitting: false,
|
||||
error: err instanceof Error ? err.message : "Failed to retry",
|
||||
});
|
||||
const msg = toErrorMessage(err, "Failed to retry");
|
||||
set({ submitting: false, error: msg });
|
||||
useToastStore.getState().push("error", msg);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -272,9 +274,21 @@ export const useSessionStore = create<SessionStateStore>((set, get) => ({
|
||||
existing.close();
|
||||
}
|
||||
const url = SessionsApi.iterateStreamUrl(worldId, stepId);
|
||||
let erroredOnce = false;
|
||||
const controller = subscribeSse(url, {
|
||||
onOpen: () => set({ sseStatus: "open" }),
|
||||
onError: () => set({ sseStatus: "error" }),
|
||||
onOpen: () => {
|
||||
erroredOnce = false;
|
||||
set({ sseStatus: "open" });
|
||||
},
|
||||
onError: () => {
|
||||
set({ sseStatus: "error" });
|
||||
// Avoid toasting on every reconnect attempt — only toast the first
|
||||
// transition into the error state.
|
||||
if (!erroredOnce) {
|
||||
erroredOnce = true;
|
||||
useToastStore.getState().push("warning", "Connection lost. Retrying…");
|
||||
}
|
||||
},
|
||||
onClose: () => set({ sseStatus: "closed" }),
|
||||
onEvent: (event) => handleEvent(get(), event, worldId),
|
||||
});
|
||||
|
||||
72
frontend/src/stores/uiSettingsStore.ts
Normal file
72
frontend/src/stores/uiSettingsStore.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { create } from "zustand";
|
||||
import { MiscApi } from "@/lib/api";
|
||||
import type { PublicSettings } from "@/types";
|
||||
|
||||
interface UiSettingsState {
|
||||
settings: PublicSettings | null;
|
||||
loaded: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
apply: (s: PublicSettings) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
page_title: "AI-RPG",
|
||||
favicon_url: "/icon.png",
|
||||
logo_url: "/icon.png",
|
||||
og_image_url: "",
|
||||
};
|
||||
|
||||
function applyToDocument(s: PublicSettings): void {
|
||||
if (s.page_title) document.title = s.page_title;
|
||||
if (s.favicon_url) {
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = s.favicon_url;
|
||||
}
|
||||
if (s.og_image_url) {
|
||||
let meta = document.querySelector<HTMLMetaElement>('meta[property="og:image"]');
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta");
|
||||
meta.setAttribute("property", "og:image");
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = s.og_image_url;
|
||||
}
|
||||
}
|
||||
|
||||
export const useUiSettingsStore = create<UiSettingsState>((set, get) => ({
|
||||
settings: null,
|
||||
loaded: false,
|
||||
fetch: async () => {
|
||||
try {
|
||||
const s = await MiscApi.publicSettings();
|
||||
applyToDocument(s);
|
||||
set({ settings: s, loaded: true });
|
||||
} catch {
|
||||
// Network or 404 — fall back to defaults but still mark as loaded so
|
||||
// the app can render.
|
||||
const fallback = DEFAULT_PUBLIC_SETTINGS;
|
||||
applyToDocument(fallback);
|
||||
set({ settings: fallback, loaded: true });
|
||||
}
|
||||
},
|
||||
apply: (s) => {
|
||||
applyToDocument(s);
|
||||
set({ settings: s, loaded: true });
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Re-fetch public UI settings. Intended to be called after admin saves
|
||||
* UI-related settings (page_title, favicon_url, logo_url, og_image_url).
|
||||
*/
|
||||
export function refreshUiSettings(): Promise<void> {
|
||||
return useUiSettingsStore.getState().fetch();
|
||||
}
|
||||
|
||||
// Kick off an initial fetch on module load (non-blocking, non-fatal).
|
||||
void useUiSettingsStore.getState().fetch();
|
||||
@@ -237,31 +237,55 @@ export interface AdminSettingsResponse {
|
||||
|
||||
export interface LlmLog {
|
||||
id: string;
|
||||
world_id: string | null;
|
||||
world_id?: string | null;
|
||||
stage: string;
|
||||
status: string;
|
||||
model: string;
|
||||
latency_ms: number | null;
|
||||
tokens: number | null;
|
||||
prompt: string | null;
|
||||
response: string | null;
|
||||
error: string | null;
|
||||
model: string | null;
|
||||
prompt_tokens: number | null;
|
||||
completion_tokens: number | null;
|
||||
error_message?: string | null;
|
||||
created_at: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface LlmLogDetail extends LlmLog {
|
||||
messages?: unknown;
|
||||
export interface LlmLogDetail {
|
||||
id: string;
|
||||
user_id?: string | null;
|
||||
world_id?: string | null;
|
||||
step_id?: string | null;
|
||||
stage: string;
|
||||
model: string;
|
||||
status: string;
|
||||
request_messages: unknown;
|
||||
request_tools?: unknown;
|
||||
response_message: unknown;
|
||||
tool_calls?: unknown;
|
||||
prompt_tokens: number | null;
|
||||
completion_tokens: number | null;
|
||||
latency_ms: number | null;
|
||||
temperature: number | null;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
users: number;
|
||||
worlds: number;
|
||||
worlds_total?: number;
|
||||
worlds_archived?: number;
|
||||
steps: number;
|
||||
avg_llm_latency_ms: number | null;
|
||||
}
|
||||
|
||||
export interface PublicSettings {
|
||||
page_title: string;
|
||||
favicon_url: string;
|
||||
logo_url: string;
|
||||
og_image_url: string;
|
||||
}
|
||||
|
||||
export interface LlmTestResult {
|
||||
ok: boolean;
|
||||
response?: string;
|
||||
|
||||
@@ -9,22 +9,22 @@ export default {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
DEFAULT: "#0f1115",
|
||||
soft: "#171a21",
|
||||
card: "#1e232c",
|
||||
DEFAULT: "rgb(var(--bg) / <alpha-value>)",
|
||||
soft: "rgb(var(--bg-soft) / <alpha-value>)",
|
||||
card: "rgb(var(--bg-card) / <alpha-value>)",
|
||||
},
|
||||
fg: {
|
||||
DEFAULT: "#e6e9ef",
|
||||
muted: "#9ca3af",
|
||||
dim: "#6b7280",
|
||||
DEFAULT: "rgb(var(--fg) / <alpha-value>)",
|
||||
muted: "rgb(var(--fg-muted) / <alpha-value>)",
|
||||
dim: "rgb(var(--fg-dim) / <alpha-value>)",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "#8b5cf6",
|
||||
hover: "#7c3aed",
|
||||
DEFAULT: "rgb(var(--accent) / <alpha-value>)",
|
||||
hover: "rgb(var(--accent-hover) / <alpha-value>)",
|
||||
},
|
||||
ok: "#10b981",
|
||||
warn: "#f59e0b",
|
||||
err: "#ef4444",
|
||||
ok: "rgb(var(--ok) / <alpha-value>)",
|
||||
warn: "rgb(var(--warn) / <alpha-value>)",
|
||||
err: "rgb(var(--err) / <alpha-value>)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Inter", "system-ui", "sans-serif"],
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/admin/IconsPanel.tsx","./src/components/admin/LlmLogsTable.tsx","./src/components/admin/SettingsPanel.tsx","./src/components/admin/StatsPanel.tsx","./src/components/admin/TestButtons.tsx","./src/components/admin/UsersTable.tsx","./src/components/auth/ProtectedRoute.tsx","./src/components/sessions/ActionInput.tsx","./src/components/sessions/ChatView.tsx","./src/components/sessions/PhaseProgress.tsx","./src/components/sessions/SseStatus.tsx","./src/components/sessions/ToolCallBubble.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/Input.tsx","./src/components/ui/JsonEditor.tsx","./src/components/ui/Modal.tsx","./src/components/ui/Navbar.tsx","./src/components/ui/Spinner.tsx","./src/components/ui/Textarea.tsx","./src/components/ui/Toast.tsx","./src/components/worlds/WorldBuilder.tsx","./src/components/worlds/WorldCard.tsx","./src/components/worlds/WorldEditor.tsx","./src/i18n/index.ts","./src/lib/api.ts","./src/lib/cn.ts","./src/lib/sse.ts","./src/pages/AdminPage.tsx","./src/pages/AdminRegisterPage.tsx","./src/pages/LoginPage.tsx","./src/pages/PlayPage.tsx","./src/pages/RegisterPage.tsx","./src/pages/WorldBuilderPage.tsx","./src/pages/WorldEditPage.tsx","./src/pages/WorldsListPage.tsx","./src/stores/authStore.ts","./src/stores/sessionStore.ts","./src/stores/toastStore.ts","./src/stores/uiSettingsStore.ts","./src/stores/uiStore.ts","./src/stores/worldsStore.ts","./src/types/index.ts"],"version":"5.9.3"}
|
||||
@@ -10,7 +10,7 @@ pydantic-settings==2.7.0
|
||||
email-validator==2.2.0 # required by pydantic.EmailStr
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.1.2
|
||||
bcrypt==4.0.1 # pinned: 4.1+ removed __about__ which breaks passlib 1.7.4
|
||||
httpx==0.27.0
|
||||
sse-starlette==1.8.2
|
||||
qdrant-client==1.9.0
|
||||
|
||||
Reference in New Issue
Block a user