Expand request review tooling and KB
This commit is contained in:
parent
ab1dfbf029
commit
cae21b61b7
16 changed files with 1258 additions and 241 deletions
|
|
@ -16,8 +16,12 @@ The goal is to give request-processing tools stable system summaries instead of
|
||||||
- Machine-readable system index.
|
- Machine-readable system index.
|
||||||
- `request-analysis-schema.json`
|
- `request-analysis-schema.json`
|
||||||
- JSON schema for structured request parsing output.
|
- JSON schema for structured request parsing output.
|
||||||
|
- `tags.json`
|
||||||
|
- Standardized request tags for queue analysis and admin review.
|
||||||
- `queue-workflow.md`
|
- `queue-workflow.md`
|
||||||
- First-pass automation and review flow.
|
- First-pass automation and review flow.
|
||||||
|
- `editor-capabilities.md`
|
||||||
|
- Shorthand capability matrix for common request-analysis questions.
|
||||||
- `systems/*.md`
|
- `systems/*.md`
|
||||||
- Human-readable system notes, organized by editor subsystem.
|
- Human-readable system notes, organized by editor subsystem.
|
||||||
|
|
||||||
|
|
@ -44,10 +48,11 @@ Suggested queue flow:
|
||||||
1. Pull a raw request submission from the launcher request store.
|
1. Pull a raw request submission from the launcher request store.
|
||||||
2. Retrieve likely systems from `systems.json` using tags, aliases, and file names.
|
2. Retrieve likely systems from `systems.json` using tags, aliases, and file names.
|
||||||
3. Provide the matching `systems/*.md` files to the local model as context.
|
3. Provide the matching `systems/*.md` files to the local model as context.
|
||||||
4. Ask the model to split the submission into atomic requests.
|
4. Require the model to use only tags from `tags.json`.
|
||||||
5. Require output that matches `request-analysis-schema.json`.
|
5. Ask the model to split the submission into atomic requests.
|
||||||
6. Validate the JSON before storing anything back into the app.
|
6. Require output that matches `request-analysis-schema.json`.
|
||||||
7. Auto-promote only high-confidence items. Keep low-confidence items in review.
|
7. Validate the JSON before storing anything back into the app.
|
||||||
|
8. Auto-promote only high-confidence items. Keep low-confidence items in review.
|
||||||
|
|
||||||
## Recommended Next Additions
|
## Recommended Next Additions
|
||||||
|
|
||||||
|
|
@ -98,6 +103,7 @@ $env:REQUEST_ANALYZER_MODEL="deepseek-v4-pro"
|
||||||
|
|
||||||
- If the model produces multiple atomic requests from one submission, keep the original submission id on every derived item.
|
- If the model produces multiple atomic requests from one submission, keep the original submission id on every derived item.
|
||||||
- If a request touches multiple systems, prefer one primary category and many tags.
|
- If a request touches multiple systems, prefer one primary category and many tags.
|
||||||
|
- Review rationale should be short, structured, and safe to show in admin UI. Do not rely on hidden model chain-of-thought.
|
||||||
- If the request is really a bug report, preserve the problem statement and write the implementation note as a likely fix path, not a promise.
|
- If the request is really a bug report, preserve the problem statement and write the implementation note as a likely fix path, not a promise.
|
||||||
- If the model cannot confidently map a request, store it as `needs_review` instead of forcing a category.
|
- If the model cannot confidently map a request, store it as `needs_review` instead of forcing a category.
|
||||||
|
|
||||||
|
|
|
||||||
69
docs/kb/editor-capabilities.md
Normal file
69
docs/kb/editor-capabilities.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Editor Capabilities Map
|
||||||
|
|
||||||
|
This file is a shorthand capability matrix for request analysis. It is intentionally less detailed than the source code, but more concrete than the original scaffold summaries.
|
||||||
|
|
||||||
|
## Tile Canvas Editing
|
||||||
|
|
||||||
|
- Owned primarily by `Layers And Tile Editing`
|
||||||
|
- Supports:
|
||||||
|
- freehand tile painting
|
||||||
|
- rectangle and circle outline strokes
|
||||||
|
- line strokes with axis lock
|
||||||
|
- erasing on the active layer
|
||||||
|
- sparse height-layer paint and erase workflows
|
||||||
|
- background-cell modes for explicit tile, transparent hole, and inherit
|
||||||
|
- Depends on:
|
||||||
|
- `Chunk Storage And Streaming`
|
||||||
|
- `Content Catalog And Records`
|
||||||
|
- `Persistence And Save Pipeline`
|
||||||
|
- `Rendering And Viewport`
|
||||||
|
|
||||||
|
## Graphics Painter
|
||||||
|
|
||||||
|
- Owned primarily by `Graphics Painter`
|
||||||
|
- Supports:
|
||||||
|
- rectangle, circle, triangle, and line tools
|
||||||
|
- outline, fill, and outline + fill variants
|
||||||
|
- shape erasers
|
||||||
|
- animation timeline editing and preview
|
||||||
|
- transform-oriented tool menus
|
||||||
|
- Depends on:
|
||||||
|
- `Content Catalog And Records`
|
||||||
|
- `Floating Window Shell`
|
||||||
|
- `Rendering And Viewport`
|
||||||
|
|
||||||
|
## Chunk Operations
|
||||||
|
|
||||||
|
- Shared by `Chunk Storage And Streaming` and `World Overview`
|
||||||
|
- Supports:
|
||||||
|
- neighborhood chunk loading
|
||||||
|
- chunk composition into the current world edit surface
|
||||||
|
- chunk move, duplicate, rotate, flip, and delete
|
||||||
|
- chunk background tile paint and restore
|
||||||
|
- bookmark and POI workflows
|
||||||
|
- Depends on:
|
||||||
|
- `Persistence And Save Pipeline`
|
||||||
|
- `Worlds`
|
||||||
|
- `Windows`
|
||||||
|
|
||||||
|
## Request Review
|
||||||
|
|
||||||
|
- Owned primarily by `Request Board`
|
||||||
|
- Supports:
|
||||||
|
- public submission intake
|
||||||
|
- standardized tag assignment
|
||||||
|
- structured review rationale
|
||||||
|
- possible options for manual triage
|
||||||
|
- admin-side edits and approval
|
||||||
|
- queue worker invocation
|
||||||
|
- Depends on:
|
||||||
|
- `Launcher`
|
||||||
|
- `Wiki`
|
||||||
|
- request-analysis worker and KB files
|
||||||
|
|
||||||
|
## Relationship Rules For Triage
|
||||||
|
|
||||||
|
- If a request asks for how a brush or rectangle tool behaves on the map canvas, start with `Layers And Tile Editing`.
|
||||||
|
- If a request asks for how a rectangle or shape tool behaves inside the art editor, start with `Graphics Painter`.
|
||||||
|
- If a request asks about chunk movement, chunk duplication, or world-scale operations, start with `World Overview`, then confirm any storage implications in `Chunk Storage And Streaming`.
|
||||||
|
- If a request is mostly about presentation, launch flow, repo links, or public site layout, start with `Launcher Home`.
|
||||||
|
|
@ -18,9 +18,10 @@ Turn raw launcher submissions into structured request items that are:
|
||||||
2. Split it into candidate atomic requests.
|
2. Split it into candidate atomic requests.
|
||||||
3. Retrieve likely systems from `docs/kb/systems.json`.
|
3. Retrieve likely systems from `docs/kb/systems.json`.
|
||||||
4. Load the matching `docs/kb/systems/*.md` files.
|
4. Load the matching `docs/kb/systems/*.md` files.
|
||||||
5. Ask the local model for JSON that matches `docs/kb/request-analysis-schema.json`.
|
5. Load the standardized request tags from `docs/kb/tags.json`.
|
||||||
6. Validate the JSON.
|
6. Ask the local model for JSON that matches `docs/kb/request-analysis-schema.json`.
|
||||||
7. Promote high-confidence items and hold low-confidence items for review.
|
7. Validate the JSON.
|
||||||
|
8. Promote high-confidence items and hold low-confidence items for review.
|
||||||
|
|
||||||
## Suggested Confidence Rules
|
## Suggested Confidence Rules
|
||||||
|
|
||||||
|
|
@ -52,10 +53,11 @@ You are processing Worldshaper editor requests.
|
||||||
You must:
|
You must:
|
||||||
- split the submission into one or more atomic requests
|
- split the submission into one or more atomic requests
|
||||||
- assign one primary category per item
|
- assign one primary category per item
|
||||||
- assign tags grounded in the provided KB
|
- assign tags grounded in the provided KB and limited to `docs/kb/tags.json`
|
||||||
- write a short descriptive title
|
- write a short descriptive title
|
||||||
- explain the user intent in plain language
|
- explain the user intent in plain language
|
||||||
- propose a likely implementation path
|
- propose a likely implementation path
|
||||||
|
- provide a short review rationale and possible options when confidence is low
|
||||||
- avoid inventing systems that are not in the KB
|
- avoid inventing systems that are not in the KB
|
||||||
- return only valid JSON matching the provided schema
|
- return only valid JSON matching the provided schema
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,17 @@
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
"maximum": 1
|
"maximum": 1
|
||||||
},
|
},
|
||||||
|
"reviewRationale": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reviewOptions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"uniqueItems": true
|
||||||
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"name": "Launcher Home",
|
"name": "Launcher Home",
|
||||||
"docPath": "docs/kb/systems/launcher-home.md",
|
"docPath": "docs/kb/systems/launcher-home.md",
|
||||||
"aliases": ["launcher", "main page", "home page", "landing page"],
|
"aliases": ["launcher", "main page", "home page", "landing page"],
|
||||||
"tags": ["Launcher", "News", "Requests", "Presentation"],
|
"tags": ["Launcher", "Request Board", "Website", "Polish"],
|
||||||
"uiSurfaces": ["Launcher hero", "News tab", "Requests tab"],
|
"uiSurfaces": ["Launcher hero", "News tab", "Requests tab"],
|
||||||
"keyFiles": ["src/WorldshaperLauncher.tsx", "src/index.css", "src/worldshaperStudio/windowing.ts"],
|
"keyFiles": ["src/WorldshaperLauncher.tsx", "src/index.css", "src/worldshaperStudio/windowing.ts"],
|
||||||
"apiEndpoints": ["/api/world-default", "/api/launcher-requests"],
|
"apiEndpoints": ["/api/world-default", "/api/launcher-requests"],
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
"name": "Request Board",
|
"name": "Request Board",
|
||||||
"docPath": "docs/kb/systems/request-board.md",
|
"docPath": "docs/kb/systems/request-board.md",
|
||||||
"aliases": ["requests", "request queue", "request intake", "request board"],
|
"aliases": ["requests", "request queue", "request intake", "request board"],
|
||||||
"tags": ["Requests", "Launcher", "Workflow", "Moderation"],
|
"tags": ["Request Board", "Launcher", "UI / Workflow", "Website"],
|
||||||
"uiSurfaces": ["Launcher Requests tab"],
|
"uiSurfaces": ["Launcher Requests tab"],
|
||||||
"keyFiles": ["src/WorldshaperLauncher.tsx", "server.js", "data/launcher_requests.json"],
|
"keyFiles": ["src/WorldshaperLauncher.tsx", "server.js", "data/launcher_requests.json"],
|
||||||
"apiEndpoints": ["/api/launcher-requests", "/api/launcher-requests/:requestId"],
|
"apiEndpoints": ["/api/launcher-requests", "/api/launcher-requests/:requestId"],
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"name": "Studio Bootstrap",
|
"name": "Studio Bootstrap",
|
||||||
"docPath": "docs/kb/systems/world-bootstrap.md",
|
"docPath": "docs/kb/systems/world-bootstrap.md",
|
||||||
"aliases": ["bootstrap", "startup", "world load", "initial load"],
|
"aliases": ["bootstrap", "startup", "world load", "initial load"],
|
||||||
"tags": ["Bootstrap", "Worlds", "Chunks", "Startup"],
|
"tags": ["Worlds", "Chunks", "Launcher", "Persistence"],
|
||||||
"uiSurfaces": ["Studio window"],
|
"uiSurfaces": ["Studio window"],
|
||||||
"keyFiles": ["src/worldshaperStudio/bootstrap.ts", "src/worldshaperStudio/main.ts", "src/worldshaperStudio/runtime.ts"],
|
"keyFiles": ["src/worldshaperStudio/bootstrap.ts", "src/worldshaperStudio/main.ts", "src/worldshaperStudio/runtime.ts"],
|
||||||
"apiEndpoints": ["/api/world-default", "/api/world/:worldId", "/api/world/:worldId/bookmarks", "/api/world/:worldId/chunks"],
|
"apiEndpoints": ["/api/world-default", "/api/world/:worldId", "/api/world/:worldId/bookmarks", "/api/world/:worldId/chunks"],
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
"name": "Chunk Storage And Streaming",
|
"name": "Chunk Storage And Streaming",
|
||||||
"docPath": "docs/kb/systems/chunk-storage-streaming.md",
|
"docPath": "docs/kb/systems/chunk-storage-streaming.md",
|
||||||
"aliases": ["chunks", "chunk loading", "world streaming", "chunk cache", "world neighborhood"],
|
"aliases": ["chunks", "chunk loading", "world streaming", "chunk cache", "world neighborhood"],
|
||||||
"tags": ["Chunks", "Streaming", "Worlds", "Performance"],
|
"tags": ["Chunks", "Worlds", "Performance", "Persistence"],
|
||||||
"uiSurfaces": ["World editor viewport", "World mode"],
|
"uiSurfaces": ["World editor viewport", "World mode"],
|
||||||
"keyFiles": ["server.js", "src/worldChunking.ts", "src/worldshaperStudio/bootstrap.ts", "src/worldshaperStudio/runtime.ts"],
|
"keyFiles": ["server.js", "src/worldChunking.ts", "src/worldshaperStudio/bootstrap.ts", "src/worldshaperStudio/runtime.ts"],
|
||||||
"apiEndpoints": ["/api/world/:worldId/chunk/:chunkX/:chunkY", "/api/world/:worldId/chunks", "/api/world/:worldId/chunks/batch-save"],
|
"apiEndpoints": ["/api/world/:worldId/chunk/:chunkX/:chunkY", "/api/world/:worldId/chunks", "/api/world/:worldId/chunks/batch-save"],
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
"name": "Layers And Tile Editing",
|
"name": "Layers And Tile Editing",
|
||||||
"docPath": "docs/kb/systems/layers-tile-editing.md",
|
"docPath": "docs/kb/systems/layers-tile-editing.md",
|
||||||
"aliases": ["layers", "tile painting", "height layers", "brushes", "eraser"],
|
"aliases": ["layers", "tile painting", "height layers", "brushes", "eraser"],
|
||||||
"tags": ["Layers", "Tiling", "Editing", "Height Layers"],
|
"tags": ["Layers", "Tiling", "UI / Workflow", "Worlds"],
|
||||||
"uiSurfaces": ["Layers sidebar", "Canvas tools", "Viewport"],
|
"uiSurfaces": ["Layers sidebar", "Canvas tools", "Viewport"],
|
||||||
"keyFiles": ["src/worldshaperStudio/interactionController.ts", "src/worldshaperStudio/runtime.ts", "src/worldshaperStudio/sidebarController.ts", "src/worldshaperStudio/mapDocumentController.ts", "src/worldshaperStudio/popupSessionStore.ts"],
|
"keyFiles": ["src/worldshaperStudio/interactionController.ts", "src/worldshaperStudio/runtime.ts", "src/worldshaperStudio/sidebarController.ts", "src/worldshaperStudio/mapDocumentController.ts", "src/worldshaperStudio/popupSessionStore.ts"],
|
||||||
"apiEndpoints": ["/api/world/:worldId/chunks/batch-save"],
|
"apiEndpoints": ["/api/world/:worldId/chunks/batch-save"],
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
"name": "Graphics Painter",
|
"name": "Graphics Painter",
|
||||||
"docPath": "docs/kb/systems/graphics-painter.md",
|
"docPath": "docs/kb/systems/graphics-painter.md",
|
||||||
"aliases": ["graphic painter", "graphics painter", "tile art", "sprite painter", "animation painter"],
|
"aliases": ["graphic painter", "graphics painter", "tile art", "sprite painter", "animation painter"],
|
||||||
"tags": ["Graphics Painter", "Animation", "Tiles", "Sprites"],
|
"tags": ["Graphics Painter", "Animation", "Content", "Windows"],
|
||||||
"uiSurfaces": ["Graphic Painter window", "Animation preview"],
|
"uiSurfaces": ["Graphic Painter window", "Animation preview"],
|
||||||
"keyFiles": ["src/worldshaperStudio/tileArtEditorWindowController.ts", "src/worldshaperStudio/graphicsDocumentHelpers.ts", "src/worldshaperStudio/importController.ts", "src/worldshaperStudio/dom.ts"],
|
"keyFiles": ["src/worldshaperStudio/tileArtEditorWindowController.ts", "src/worldshaperStudio/graphicsDocumentHelpers.ts", "src/worldshaperStudio/importController.ts", "src/worldshaperStudio/dom.ts"],
|
||||||
"apiEndpoints": ["/api/content/images", "/api/content/sprites", "/api/content/tiles", "/api/images", "/api/images/:filename"],
|
"apiEndpoints": ["/api/content/images", "/api/content/sprites", "/api/content/tiles", "/api/images", "/api/images/:filename"],
|
||||||
|
|
@ -79,7 +79,7 @@
|
||||||
"name": "Rendering And Viewport",
|
"name": "Rendering And Viewport",
|
||||||
"docPath": "docs/kb/systems/rendering-viewport.md",
|
"docPath": "docs/kb/systems/rendering-viewport.md",
|
||||||
"aliases": ["renderer", "pixi", "viewport", "camera", "draw", "chunk culling"],
|
"aliases": ["renderer", "pixi", "viewport", "camera", "draw", "chunk culling"],
|
||||||
"tags": ["Rendering", "Viewport", "Pixi", "Performance"],
|
"tags": ["Rendering", "Performance", "Worlds", "Chunks"],
|
||||||
"uiSurfaces": ["World canvas", "Debug overlay"],
|
"uiSurfaces": ["World canvas", "Debug overlay"],
|
||||||
"keyFiles": ["src/worldshaperStudio/renderController.ts", "src/worldshaperStudio/pixiTileStageController.ts", "src/worldshaperStudio/pixiSceneRebuildHelpers.ts", "src/worldshaperStudio/pixiChunkSurfaceHelpers.ts", "src/worldshaperStudio/overlayRenderer.ts"],
|
"keyFiles": ["src/worldshaperStudio/renderController.ts", "src/worldshaperStudio/pixiTileStageController.ts", "src/worldshaperStudio/pixiSceneRebuildHelpers.ts", "src/worldshaperStudio/pixiChunkSurfaceHelpers.ts", "src/worldshaperStudio/overlayRenderer.ts"],
|
||||||
"apiEndpoints": [],
|
"apiEndpoints": [],
|
||||||
|
|
@ -91,7 +91,7 @@
|
||||||
"name": "World Overview",
|
"name": "World Overview",
|
||||||
"docPath": "docs/kb/systems/world-overview.md",
|
"docPath": "docs/kb/systems/world-overview.md",
|
||||||
"aliases": ["overview", "world overview", "chunk overview", "overview map"],
|
"aliases": ["overview", "world overview", "chunk overview", "overview map"],
|
||||||
"tags": ["World Overview", "Chunks", "Bookmarks", "Navigation"],
|
"tags": ["World Overview", "Chunks", "Windows", "Worlds"],
|
||||||
"uiSurfaces": ["World Overview window"],
|
"uiSurfaces": ["World Overview window"],
|
||||||
"keyFiles": ["src/worldshaperStudio/worldOverviewWindowController.ts", "src/worldshaperStudio/dom.ts", "server.js"],
|
"keyFiles": ["src/worldshaperStudio/worldOverviewWindowController.ts", "src/worldshaperStudio/dom.ts", "server.js"],
|
||||||
"apiEndpoints": ["/api/world/:worldId/overview", "/api/world/:worldId/bookmarks"],
|
"apiEndpoints": ["/api/world/:worldId/overview", "/api/world/:worldId/bookmarks"],
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
"name": "Persistence And Save Pipeline",
|
"name": "Persistence And Save Pipeline",
|
||||||
"docPath": "docs/kb/systems/persistence-save-pipeline.md",
|
"docPath": "docs/kb/systems/persistence-save-pipeline.md",
|
||||||
"aliases": ["save", "saving", "persist", "world save", "content save"],
|
"aliases": ["save", "saving", "persist", "world save", "content save"],
|
||||||
"tags": ["Persistence", "Saving", "APIs", "Worlds", "Content"],
|
"tags": ["Persistence", "Content", "Chunks", "Worlds"],
|
||||||
"uiSurfaces": ["Toolbar save flow", "Status messages"],
|
"uiSurfaces": ["Toolbar save flow", "Status messages"],
|
||||||
"keyFiles": ["src/worldshaperStudio/persistenceController.ts", "src/worldshaperStudio/historyController.ts", "src/worldshaperStudio/historyStateStore.ts", "server.js"],
|
"keyFiles": ["src/worldshaperStudio/persistenceController.ts", "src/worldshaperStudio/historyController.ts", "src/worldshaperStudio/historyStateStore.ts", "server.js"],
|
||||||
"apiEndpoints": ["/api/world/:worldId/chunks/batch-save", "/api/content/:type", "/api/editor-settings", "/api/catalog-meta"],
|
"apiEndpoints": ["/api/world/:worldId/chunks/batch-save", "/api/content/:type", "/api/editor-settings", "/api/catalog-meta"],
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
"name": "Floating Window Shell",
|
"name": "Floating Window Shell",
|
||||||
"docPath": "docs/kb/systems/floating-window-shell.md",
|
"docPath": "docs/kb/systems/floating-window-shell.md",
|
||||||
"aliases": ["floating window", "popout", "windowing", "tool windows"],
|
"aliases": ["floating window", "popout", "windowing", "tool windows"],
|
||||||
"tags": ["Windows", "UI", "Launcher", "Tooling"],
|
"tags": ["Windows", "Launcher", "UI / Workflow", "Graphics Painter"],
|
||||||
"uiSurfaces": ["Studio popout", "Tool windows", "Overlay layer"],
|
"uiSurfaces": ["Studio popout", "Tool windows", "Overlay layer"],
|
||||||
"keyFiles": ["src/worldshaperStudio/windowing.ts", "src/worldshaperStudio/floatingWindowUtils.ts", "src/worldshaperStudio/toolWindowController.ts", "src/worldshaperStudio/changelogSplashWindowController.ts", "src/worldshaperStudio/engineOverrideWindowController.ts"],
|
"keyFiles": ["src/worldshaperStudio/windowing.ts", "src/worldshaperStudio/floatingWindowUtils.ts", "src/worldshaperStudio/toolWindowController.ts", "src/worldshaperStudio/changelogSplashWindowController.ts", "src/worldshaperStudio/engineOverrideWindowController.ts"],
|
||||||
"apiEndpoints": [],
|
"apiEndpoints": [],
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
"name": "Content Catalog And Records",
|
"name": "Content Catalog And Records",
|
||||||
"docPath": "docs/kb/systems/content-catalog-records.md",
|
"docPath": "docs/kb/systems/content-catalog-records.md",
|
||||||
"aliases": ["content", "tiles", "sprites", "images", "catalog", "records"],
|
"aliases": ["content", "tiles", "sprites", "images", "catalog", "records"],
|
||||||
"tags": ["Content", "Tiles", "Sprites", "Images", "Schemas"],
|
"tags": ["Content", "Graphics Painter", "Tiling", "Persistence"],
|
||||||
"uiSurfaces": ["Content records", "Graphic Painter inputs", "Tile palette"],
|
"uiSurfaces": ["Content records", "Graphic Painter inputs", "Tile palette"],
|
||||||
"keyFiles": ["server.js", "src/App.tsx", "src/components/worldshaperShared.ts", "src/worldshaperStudio/graphicsDocumentHelpers.ts", "src/worldshaperStudio/importController.ts"],
|
"keyFiles": ["server.js", "src/App.tsx", "src/components/worldshaperShared.ts", "src/worldshaperStudio/graphicsDocumentHelpers.ts", "src/worldshaperStudio/importController.ts"],
|
||||||
"apiEndpoints": ["/api/content/:type", "/api/content/tiles/:tileId/delete", "/api/content/sprites/:spriteId/delete", "/api/catalog-meta", "/api/images", "/api/images/:filename"],
|
"apiEndpoints": ["/api/content/:type", "/api/content/tiles/:tileId/delete", "/api/content/sprites/:spriteId/delete", "/api/catalog-meta", "/api/images", "/api/images/:filename"],
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,47 @@
|
||||||
# Chunk Storage And Streaming
|
# Chunk Storage And Streaming
|
||||||
|
|
||||||
## What It Does
|
## What It Owns
|
||||||
|
|
||||||
World data is stored as per-chunk JSON files and loaded into the editor as chunk neighborhoods around a center chunk. The runtime caches chunk payloads, shifts neighborhoods as the viewport moves, and saves dirty chunks in batches.
|
This system owns the world-mode chunk model: loading chunk neighborhoods, composing chunk data into the current editor session, and persisting chunk updates back to disk.
|
||||||
|
|
||||||
## Key Files
|
## Core Behaviors
|
||||||
|
|
||||||
|
- Load a neighborhood of chunks around a world position.
|
||||||
|
- Compose room layers from chunk-local rows into a temporary larger editing surface.
|
||||||
|
- Compose sparse height layers from chunk data.
|
||||||
|
- Compose placed instances and overlays from chunk payloads.
|
||||||
|
- Batch-save chunk changes back to the API.
|
||||||
|
|
||||||
|
## Important Data
|
||||||
|
|
||||||
|
Chunk payloads can contribute:
|
||||||
|
|
||||||
|
- `roomLayers`
|
||||||
|
- `heightLayers`
|
||||||
|
- `instances`
|
||||||
|
- `backgroundTileId`
|
||||||
|
- chunk coordinates and dimensions
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
- `server.js`
|
- `server.js`
|
||||||
- `src/worldChunking.ts`
|
- `src/worldChunking.ts`
|
||||||
- `src/worldshaperStudio/bootstrap.ts`
|
- `src/worldshaperStudio/bootstrap.ts`
|
||||||
- `src/worldshaperStudio/runtime.ts`
|
- `src/worldshaperStudio/runtime.ts`
|
||||||
- `src/worldshaperStudio/persistenceController.ts`
|
|
||||||
|
|
||||||
## Endpoints It Uses
|
## Known Behavior Notes
|
||||||
|
|
||||||
- `GET /api/world/:worldId/chunk/:chunkX/:chunkY`
|
- The bootstrap path currently loads a neighborhood around an initial bookmark or world center instead of streaming every chunk in the world.
|
||||||
- `POST /api/world/:worldId/chunk/:chunkX/:chunkY`
|
- Exact chunk access is grid-based, so many performance requests should start with profiling the current neighborhood/cache path before proposing spatial trees.
|
||||||
- `GET /api/world/:worldId/chunks`
|
- World mode is not editing one isolated chunk in a vacuum; it edits a composed surface backed by many chunk records.
|
||||||
- `POST /api/world/:worldId/chunks/batch-save`
|
|
||||||
|
|
||||||
## Important Data And Rules
|
## Relationships
|
||||||
|
|
||||||
- chunks are stored as `content/worlds/<worldId>/chunks/<x>_<y>.json`
|
- Feeds `Layers And Tile Editing`, which paints onto the composed chunk neighborhood.
|
||||||
- world neighborhoods are requested as a square around a center chunk
|
- Feeds `World Overview`, which visualizes and transforms chunk-level records.
|
||||||
- runtime keeps a chunk cache and tracks dirty chunk keys
|
- Depends on `Persistence And Save Pipeline` to write chunk mutations.
|
||||||
- chunk payloads include `roomLayers`, `heightLayers`, `instances`, and optional `backgroundTileId`
|
- Feeds `Rendering And Viewport`, which only draws what the currently loaded chunk neighborhood exposes.
|
||||||
|
|
||||||
## Invariants And Constraints
|
## Triage Hints
|
||||||
|
|
||||||
- Exact chunk lookup is grid-based and direct.
|
Requests about chunk loading, chunk transforms, chunk duplication, world streaming, chunk persistence, spatial indexing, and neighborhood performance should start here.
|
||||||
- The editor does not load the entire world for normal editing.
|
|
||||||
- Small tile edits must sync back into the correct cached chunk.
|
|
||||||
- Save flow should preserve chunk-local edits without rebuilding unrelated chunks.
|
|
||||||
|
|
||||||
## Common Request Themes
|
|
||||||
|
|
||||||
- chunk loading speed
|
|
||||||
- chunk streaming behavior
|
|
||||||
- per-chunk backgrounds
|
|
||||||
- chunk transforms, duplication, deletion
|
|
||||||
- performance questions around indexing and caching
|
|
||||||
|
|
||||||
## Triage Questions
|
|
||||||
|
|
||||||
- Is the request about storage, fetch patterns, cache behavior, or save behavior?
|
|
||||||
- Does it affect only visible chunks or the full world?
|
|
||||||
- Is the user asking for chunk-local data, or freeform world overlays that break chunk assumptions?
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,55 @@
|
||||||
# Graphics Painter
|
# Graphics Painter
|
||||||
|
|
||||||
## What It Does
|
## What It Owns
|
||||||
|
|
||||||
The Graphics Painter is the tile and sprite art editing window. It handles pixel editing, frame management, animation timeline editing, preview playback, and import-oriented art workflows.
|
This is the dedicated art-editing environment for tile and sprite graphics. It covers shape tools, erasers, transforms, previewing, and animation timeline editing for the currently opened asset.
|
||||||
|
|
||||||
## Key Files
|
## Core Behaviors
|
||||||
|
|
||||||
|
- Shape drawing modes:
|
||||||
|
- rectangle
|
||||||
|
- circle
|
||||||
|
- triangle
|
||||||
|
- line
|
||||||
|
- Shape variants:
|
||||||
|
- outline
|
||||||
|
- fill
|
||||||
|
- outline + fill
|
||||||
|
- Shape erasers:
|
||||||
|
- rectangle
|
||||||
|
- circle
|
||||||
|
- triangle
|
||||||
|
- Transform workflows available through the painter menu.
|
||||||
|
- Animation timeline controls, frame order, playback speed, and preview.
|
||||||
|
|
||||||
|
## Important UI Surfaces
|
||||||
|
|
||||||
|
- Graphic Painter window
|
||||||
|
- Tool menu and tooltip menus
|
||||||
|
- Animation timeline row
|
||||||
|
- Preview card and frame preview
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
- `src/worldshaperStudio/tileArtEditorWindowController.ts`
|
- `src/worldshaperStudio/tileArtEditorWindowController.ts`
|
||||||
- `src/worldshaperStudio/graphicsDocumentHelpers.ts`
|
- `src/worldshaperStudio/graphicsDocumentHelpers.ts`
|
||||||
- `src/worldshaperStudio/importController.ts`
|
- `src/worldshaperStudio/importController.ts`
|
||||||
- `src/worldshaperStudio/dom.ts`
|
- `src/worldshaperStudio/dom.ts`
|
||||||
|
|
||||||
## Endpoints It Uses
|
## Known Tool Details
|
||||||
|
|
||||||
- `GET /api/images`
|
- Shape menus are nested by shape and variant, rather than being a single flat tool list.
|
||||||
- `GET /api/images/:filename`
|
- The active draw tool and active eraser shape are tracked separately.
|
||||||
- `POST /api/content/images`
|
- The painter already knows about animation preview, frame creation, and per-frame editing; many animation requests should be phrased as extensions to an existing timeline rather than brand-new systems.
|
||||||
- `POST /api/content/sprites`
|
- The preview and animation controls are editor-side only; runtime rendering requests often cross into `Rendering And Viewport`.
|
||||||
- `POST /api/content/tiles`
|
|
||||||
|
|
||||||
## Important Data And Rules
|
## Relationships
|
||||||
|
|
||||||
- graphics records may include animation frame data and playback metadata
|
- Depends on `Content Catalog And Records` for source records and saved asset definitions.
|
||||||
- preview behavior is maintained in painter-local UI state
|
- Depends on `Floating Window Shell` because the painter lives inside a movable editor window.
|
||||||
- art changes eventually flow into tile and sprite content records
|
- Feeds `Rendering And Viewport` when edited assets are later shown in the live map renderer.
|
||||||
- imports and previews are tightly tied to painter usability
|
- Intersects with `Animation`-tagged requests when frame behavior changes.
|
||||||
|
|
||||||
## Invariants And Constraints
|
## Triage Hints
|
||||||
|
|
||||||
- the painter is a floating tool window, not a docked pane
|
Requests about sprite drawing, tile art tools, frame duplication, painter transforms, selection or move workflows, and editor-side animation controls should usually start here.
|
||||||
- animation preview should reflect current frame ordering and speed settings
|
|
||||||
- save behavior must preserve frame structure and timing metadata
|
|
||||||
- painter workflows often depend on tile and sprite schema conventions
|
|
||||||
|
|
||||||
## Common Request Themes
|
|
||||||
|
|
||||||
- move or selection tools
|
|
||||||
- frame duplication and frame management
|
|
||||||
- animation controls
|
|
||||||
- import workflow improvements
|
|
||||||
- better preview behavior
|
|
||||||
|
|
||||||
## Triage Questions
|
|
||||||
|
|
||||||
- Is this request about art authoring, record storage, or runtime playback?
|
|
||||||
- Does it affect tiles, sprites, or both?
|
|
||||||
- Is the requested behavior inside the painter window only?
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,36 @@
|
||||||
# Launcher Home
|
# Launcher Home
|
||||||
|
|
||||||
## What It Does
|
## What It Owns
|
||||||
|
|
||||||
The launcher is the public-facing entry point for Worldshaper. It presents the hero card, opens the editor in its own floating window, links to the repo, and hosts the News and Requests tabs.
|
The launcher is the public-facing web shell for Worldshaper. It handles editor launch, launch presentation, release notes, repo navigation, and the visible request board entry point.
|
||||||
|
|
||||||
## Key Files
|
## Core Behaviors
|
||||||
|
|
||||||
|
- Open the editor in a separate floating window.
|
||||||
|
- Show the release/news presentation.
|
||||||
|
- Show the Requests board.
|
||||||
|
- Swap between public request browsing and protected admin review mode.
|
||||||
|
- Present launcher artwork and page-level theming.
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
- `src/WorldshaperLauncher.tsx`
|
- `src/WorldshaperLauncher.tsx`
|
||||||
- `src/index.css`
|
- `src/index.css`
|
||||||
- `src/worldshaperStudio/windowing.ts`
|
- `src/worldshaperStudio/windowing.ts`
|
||||||
|
|
||||||
## Endpoints It Uses
|
## Important UI Surfaces
|
||||||
|
|
||||||
- `GET /api/world-default`
|
- Hero launch card
|
||||||
- `GET /api/launcher-requests`
|
- News tab
|
||||||
|
- Requests tab
|
||||||
|
- Protected admin unlock flow
|
||||||
|
|
||||||
## Important State
|
## Relationships
|
||||||
|
|
||||||
- launch state: ready, opening, opened, blocked, error
|
- Depends on `Floating Window Shell` for popup launch behavior.
|
||||||
- active board tab: news or requests
|
- Depends on `Request Board` for request intake and moderation UI.
|
||||||
- request list, filters, and expanded request rows
|
- Intersects with `Website` and `Polish` requests more than the editor-runtime systems do.
|
||||||
- resolved default world id
|
|
||||||
|
|
||||||
## Invariants And Constraints
|
## Triage Hints
|
||||||
|
|
||||||
- The editor is intended to open in a separate popup-style window.
|
Requests about the landing page, launch flow, background presentation, release content, public request visibility, and repo-linking should start here.
|
||||||
- The launcher should still work if the default world lookup fails by falling back to `overworld`.
|
|
||||||
- Browser popup blocking is a real failure mode and must be surfaced to the user.
|
|
||||||
|
|
||||||
## Common Request Themes
|
|
||||||
|
|
||||||
- page styling and presentation
|
|
||||||
- launch flow and popup behavior
|
|
||||||
- repo button or external links
|
|
||||||
- tab layout and launcher information architecture
|
|
||||||
|
|
||||||
## Triage Questions
|
|
||||||
|
|
||||||
- Is this about the launcher page itself, or the studio window after launch?
|
|
||||||
- Does it affect only presentation, or also request API behavior?
|
|
||||||
- Does it depend on popup/window behavior?
|
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,55 @@
|
||||||
# Layers And Tile Editing
|
# Layers And Tile Editing
|
||||||
|
|
||||||
## What It Does
|
## What It Owns
|
||||||
|
|
||||||
This subsystem handles normal tile painting, erasing, selection, visible layer management, sparse height layers, and the mapping between visible document edits and stored chunk data.
|
This system owns the main world-canvas tile workflow: selecting a room layer, choosing a tile brush, painting, erasing, and editing sparse height layers in world mode.
|
||||||
|
|
||||||
## Key Files
|
## Core Behaviors
|
||||||
|
|
||||||
|
- Freehand tile painting on the active layer.
|
||||||
|
- Shape strokes for the world canvas:
|
||||||
|
- freehand
|
||||||
|
- rectangle outline
|
||||||
|
- circle outline
|
||||||
|
- line with axis lock behavior
|
||||||
|
- Height-layer painting modes:
|
||||||
|
- single or freehand paint
|
||||||
|
- rectangle fill
|
||||||
|
- circle fill
|
||||||
|
- erase variants for the same shapes
|
||||||
|
- Background-cell editing modes:
|
||||||
|
- explicit tile stamp
|
||||||
|
- transparent hole
|
||||||
|
- inherit world background
|
||||||
|
|
||||||
|
## Important UI Surfaces
|
||||||
|
|
||||||
|
- Layers sidebar
|
||||||
|
- Height layer list
|
||||||
|
- Canvas brush interactions
|
||||||
|
- Background mode button and preview
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
- `src/worldshaperStudio/interactionController.ts`
|
- `src/worldshaperStudio/interactionController.ts`
|
||||||
- `src/worldshaperStudio/runtime.ts`
|
|
||||||
- `src/worldshaperStudio/sidebarController.ts`
|
- `src/worldshaperStudio/sidebarController.ts`
|
||||||
|
- `src/worldshaperStudio/runtime.ts`
|
||||||
- `src/worldshaperStudio/mapDocumentController.ts`
|
- `src/worldshaperStudio/mapDocumentController.ts`
|
||||||
- `src/worldshaperStudio/popupSessionStore.ts`
|
|
||||||
- `src/worldshaperStudio/dom.ts`
|
|
||||||
|
|
||||||
## Endpoints It Uses
|
## Known Tool Details
|
||||||
|
|
||||||
- primarily saved through `POST /api/world/:worldId/chunks/batch-save`
|
- Tile strokes register history entries with shape-specific labels such as rectangle stroke, circle stroke, brush stroke, and erase stroke.
|
||||||
|
- Height strokes register their own history labels and track the target Z layer.
|
||||||
|
- Layer editing is tightly coupled to the currently loaded chunk neighborhood in world mode.
|
||||||
|
- Brush behavior depends on catalog tile metadata resolved through runtime helpers.
|
||||||
|
|
||||||
## Important Data And Rules
|
## Relationships
|
||||||
|
|
||||||
- room layer `0` is the base layer
|
- Depends on `Content Catalog And Records` for tile definitions and brush metadata.
|
||||||
- non-base layers use sparse or transparent-style fill semantics
|
- Depends on `Chunk Storage And Streaming` for the currently editable chunk neighborhood.
|
||||||
- height layers are separate structures from room layers
|
- Depends on `Persistence And Save Pipeline` for actually writing edits back to storage.
|
||||||
- visibility is tracked in popup session state
|
- Feeds `Rendering And Viewport`, which visualizes the edited layers and sparse height patches.
|
||||||
- painting in world mode must mark affected chunks dirty
|
|
||||||
|
|
||||||
## Invariants And Constraints
|
## Triage Hints
|
||||||
|
|
||||||
- layer ordering matters to rendering and selection behavior
|
Requests about rectangle tools, brush behavior, height painting, active layer confusion, background stamping, and tile-placement modes should usually start here, even when they also touch rendering or chunk persistence.
|
||||||
- layer visibility must not destroy layer data
|
|
||||||
- tile edits in world mode must map local viewport coordinates back to world chunk coordinates
|
|
||||||
- height layers are not the same thing as standard room layers
|
|
||||||
|
|
||||||
## Common Request Themes
|
|
||||||
|
|
||||||
- better layer reordering
|
|
||||||
- more precise tiling tools
|
|
||||||
- height/elevation features
|
|
||||||
- snapping or unsnapped placement
|
|
||||||
- erase or selection behavior
|
|
||||||
|
|
||||||
## Triage Questions
|
|
||||||
|
|
||||||
- Is this about normal room layers or height layers?
|
|
||||||
- Does the request affect paint semantics, visibility, ordering, or storage?
|
|
||||||
- Does it apply to map mode, world mode, or both?
|
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,47 @@
|
||||||
# Request Board
|
# Request Board
|
||||||
|
|
||||||
## What It Does
|
## What It Owns
|
||||||
|
|
||||||
The request board lives on the launcher and stores lightweight user submissions. It currently supports pending and active request states, filtering, expansion of active items, and deletion with confirmation.
|
The request board is the launcher-side intake, queue, moderation, and review surface for product requests. It stores raw submissions, structured analysis, active request rows, and admin-only review actions.
|
||||||
|
|
||||||
## Key Files
|
## Core Behaviors
|
||||||
|
|
||||||
- `src/WorldshaperLauncher.tsx`
|
- Save public request submissions.
|
||||||
- `server.js`
|
- Keep raw source text alongside normalized active requests.
|
||||||
- `data/launcher_requests.json`
|
- Store structured analysis metadata for pending requests.
|
||||||
|
- Filter by status and standardized tags.
|
||||||
|
- Protect admin review actions behind server-side password checks.
|
||||||
|
- Promote reviewed request items into active request rows.
|
||||||
|
|
||||||
## Endpoints It Uses
|
## Important Endpoints
|
||||||
|
|
||||||
- `GET /api/launcher-requests`
|
- `GET /api/launcher-requests`
|
||||||
- `POST /api/launcher-requests`
|
- `POST /api/launcher-requests`
|
||||||
- `PATCH /api/launcher-requests/:requestId`
|
- `PATCH /api/launcher-requests/:requestId`
|
||||||
- `DELETE /api/launcher-requests/:requestId`
|
- `DELETE /api/launcher-requests/:requestId`
|
||||||
|
- `POST /api/launcher-requests/:requestId/process-analysis`
|
||||||
|
- `POST /api/launcher-requests/process-pending`
|
||||||
|
- `GET /api/launcher-request-meta`
|
||||||
|
- `POST /api/admin/auth-check`
|
||||||
|
|
||||||
## Important Data Shape
|
## Important Data Shape
|
||||||
|
|
||||||
Current request records include:
|
Request records can now contain:
|
||||||
|
|
||||||
- `id`
|
- public fields such as title, category, tags, summary, and implementation notes
|
||||||
- `sourceSubmissionId`
|
- raw `sourceText`
|
||||||
- `title`
|
- `analysis.state`
|
||||||
- `status`
|
- `analysis.items[]`
|
||||||
- `category`
|
- per-item review rationale
|
||||||
- `tags`
|
- per-item possible options
|
||||||
- `sourceText`
|
- structured affected-system and affected-file hints
|
||||||
- `summary`
|
|
||||||
- `implementationNotes`
|
|
||||||
- `createdAt`
|
|
||||||
- `updatedAt`
|
|
||||||
|
|
||||||
## Invariants And Constraints
|
## Relationships
|
||||||
|
|
||||||
- New submissions enter as pending unless promoted.
|
- Depends on `Launcher Home` for the public site presentation.
|
||||||
- One raw submission may contain multiple real feature requests.
|
- Depends on the KB under `docs/kb/` for model-grounded request parsing.
|
||||||
- Active requests should be normalized, titled, and tagged.
|
- Depends on the request-analysis worker for automated triage.
|
||||||
- Deletion is destructive and should remain explicit.
|
|
||||||
|
|
||||||
## Common Request Themes
|
## Triage Hints
|
||||||
|
|
||||||
- parse and split user submissions
|
Requests about queue automation, moderation, review UI, request splitting, approval workflows, structured tags, and admin tooling should start here.
|
||||||
- request tags and filtering
|
|
||||||
- moderation and cleanup
|
|
||||||
- queue automation and confidence review
|
|
||||||
|
|
||||||
## Triage Questions
|
|
||||||
|
|
||||||
- Is the user asking to change request storage, presentation, or parsing behavior?
|
|
||||||
- Should one submission become many active requests?
|
|
||||||
- Is the request really a bug report or a feature idea?
|
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,44 @@
|
||||||
# World Overview
|
# World Overview
|
||||||
|
|
||||||
## What It Does
|
## What It Owns
|
||||||
|
|
||||||
World Overview is a separate window that displays a coarse map of all known chunks, supports bookmark navigation, and exposes chunk operations such as move, duplicate, rotate, flip, delete, and background adjustments.
|
The World Overview is the large-scale chunk map used for navigation, chunk inspection, bookmark placement, and chunk-level operations that would be awkward from the tile canvas alone.
|
||||||
|
|
||||||
## Key Files
|
## Core Behaviors
|
||||||
|
|
||||||
|
- Navigate directly to a chunk.
|
||||||
|
- Move chunk content.
|
||||||
|
- Duplicate chunk tiles.
|
||||||
|
- Flip and rotate chunk content.
|
||||||
|
- Delete a chunk and clear its tiles, height data, and placed entities.
|
||||||
|
- Paint or restore the chunk background tile.
|
||||||
|
- Create POIs and bookmarks from chunk positions.
|
||||||
|
|
||||||
|
## Important UI Surfaces
|
||||||
|
|
||||||
|
- World Overview floating window
|
||||||
|
- Chunk context menu
|
||||||
|
- Pending chunk-action flow
|
||||||
|
- Bookmark and POI display
|
||||||
|
|
||||||
|
## Important Files
|
||||||
|
|
||||||
- `src/worldshaperStudio/worldOverviewWindowController.ts`
|
- `src/worldshaperStudio/worldOverviewWindowController.ts`
|
||||||
- `src/worldshaperStudio/dom.ts`
|
- `src/worldshaperStudio/dom.ts`
|
||||||
- `server.js`
|
- `server.js`
|
||||||
|
|
||||||
## Endpoints It Uses
|
## Known Behavior Notes
|
||||||
|
|
||||||
- `GET /api/world/:worldId/overview`
|
- The chunk context menu is already a rich operations hub, so many requests in this area are extensions to an existing menu rather than requests for a brand-new system.
|
||||||
- `GET /api/world/:worldId/bookmarks`
|
- Pending move and duplicate actions are stateful; the overview can wait for a second chunk selection before executing.
|
||||||
- bookmark writes flow through the world save path
|
- The overview also exposes quick background-painting workflows at the chunk level.
|
||||||
|
|
||||||
## Important Data And Rules
|
## Relationships
|
||||||
|
|
||||||
- overview currently requests the full set of world chunks from the server
|
- Depends on `Chunk Storage And Streaming` for chunk records and chunk dimensions.
|
||||||
- it draws its own chunk preview surfaces
|
- Depends on `Windows` because the overview lives in its own floating tool window.
|
||||||
- it merges server chunks with cached client chunks to reflect unsaved local state
|
- Often intersects with `Layers` and `Tiling` when users want higher-level terrain or chunk painting workflows.
|
||||||
- chunk context menus expose world-level operations
|
|
||||||
|
|
||||||
## Invariants And Constraints
|
## Triage Hints
|
||||||
|
|
||||||
- overview is only available in world mode
|
Requests about chunk move, duplicate, rotate, delete, POIs, overview navigation, and world-scale map management should begin here.
|
||||||
- overview actions should not silently discard unsaved chunk edits
|
|
||||||
- chunk transform actions must preserve chunk addressing rules
|
|
||||||
- the full overview payload can become a performance hotspot on larger worlds
|
|
||||||
|
|
||||||
## Common Request Themes
|
|
||||||
|
|
||||||
- chunk operations
|
|
||||||
- world navigation
|
|
||||||
- bookmark workflows
|
|
||||||
- overview performance
|
|
||||||
- better high-level visualization
|
|
||||||
|
|
||||||
## Triage Questions
|
|
||||||
|
|
||||||
- Is the issue specific to the overview window, or all world editing?
|
|
||||||
- Does it affect overview drawing only, or also chunk save/load behavior?
|
|
||||||
- Is the request about overview scale, navigation, or chunk manipulation?
|
|
||||||
|
|
|
||||||
132
docs/kb/tags.json
Normal file
132
docs/kb/tags.json
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"generatedFromRepoDate": "2026-06-27",
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"id": "launcher",
|
||||||
|
"label": "Launcher",
|
||||||
|
"description": "The public landing page and launch flow for Worldshaper Studio.",
|
||||||
|
"aliases": ["launcher home", "home page", "landing page", "main page"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "request-board",
|
||||||
|
"label": "Request Board",
|
||||||
|
"description": "The launcher request queue, review tooling, and moderation workflows.",
|
||||||
|
"aliases": ["requests", "request queue", "moderation", "request intake"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chunks",
|
||||||
|
"label": "Chunks",
|
||||||
|
"description": "Chunk storage, chunk transforms, chunk loading, and chunk-level map operations.",
|
||||||
|
"aliases": ["chunk", "chunk loading", "chunk storage", "chunk overview"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "layers",
|
||||||
|
"label": "Layers",
|
||||||
|
"description": "Base room layers, height layers, and layer management workflows.",
|
||||||
|
"aliases": ["layer", "height layers", "z layers"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tiling",
|
||||||
|
"label": "Tiling",
|
||||||
|
"description": "Tile painting, shape strokes, grid editing, and tile placement workflows.",
|
||||||
|
"aliases": ["tile", "tile editing", "tile painting", "brush"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "graphics-painter",
|
||||||
|
"label": "Graphics Painter",
|
||||||
|
"description": "The tile and sprite art editor, including transforms and frame editing.",
|
||||||
|
"aliases": ["graphic painter", "tile art", "sprite painter", "art editor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rendering",
|
||||||
|
"label": "Rendering",
|
||||||
|
"description": "Viewport drawing, visual composition, and renderer-facing behavior.",
|
||||||
|
"aliases": ["renderer", "viewport", "pixi", "draw"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "animation",
|
||||||
|
"label": "Animation",
|
||||||
|
"description": "Frame timelines, animation playback, and animated graphics behavior.",
|
||||||
|
"aliases": ["animated", "timeline", "frame playback"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "content",
|
||||||
|
"label": "Content",
|
||||||
|
"description": "Content records, tile and sprite catalogs, imported images, and asset metadata.",
|
||||||
|
"aliases": ["assets", "catalog", "tiles", "sprites", "images"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "world-overview",
|
||||||
|
"label": "World Overview",
|
||||||
|
"description": "The large-scale overview map, navigation, bookmarks, and chunk context actions.",
|
||||||
|
"aliases": ["overview", "world map", "bookmarks", "poi"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "windows",
|
||||||
|
"label": "Windows",
|
||||||
|
"description": "Floating windows, popouts, and dockable or movable tool shells.",
|
||||||
|
"aliases": ["windowing", "popout", "tool windows", "floating window"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "persistence",
|
||||||
|
"label": "Persistence",
|
||||||
|
"description": "Saving, history, API persistence, and data write pipelines.",
|
||||||
|
"aliases": ["saving", "save pipeline", "history", "undo", "redo"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "performance",
|
||||||
|
"label": "Performance",
|
||||||
|
"description": "Profiling, loading speed, rendering cost, and optimization work.",
|
||||||
|
"aliases": ["optimization", "slow", "lag", "streaming"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ui-workflow",
|
||||||
|
"label": "UI / Workflow",
|
||||||
|
"description": "Interaction design, prompts, confirmations, and cross-tool user flow improvements.",
|
||||||
|
"aliases": ["workflow", "ux", "ui", "quality of life", "qol"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "worlds",
|
||||||
|
"label": "Worlds",
|
||||||
|
"description": "World-level bootstrap, defaults, map metadata, and multi-world concerns.",
|
||||||
|
"aliases": ["world", "world bootstrap", "startup"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "website",
|
||||||
|
"label": "Website",
|
||||||
|
"description": "The public site, launch presentation, and web-facing pages outside the editor runtime.",
|
||||||
|
"aliases": ["site", "web", "homepage"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "repo",
|
||||||
|
"label": "Repo",
|
||||||
|
"description": "Repository, source control, and Forgejo-facing workflows.",
|
||||||
|
"aliases": ["repository", "forgejo", "git"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "wiki",
|
||||||
|
"label": "Wiki",
|
||||||
|
"description": "Internal docs, guides, and knowledge-base style content.",
|
||||||
|
"aliases": ["kb", "knowledge base", "docs", "documentation"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "polish",
|
||||||
|
"label": "Polish",
|
||||||
|
"description": "Small fit-and-finish improvements that improve feel, clarity, or consistency.",
|
||||||
|
"aliases": ["cleanup", "fit and finish", "presentation"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "general",
|
||||||
|
"label": "General",
|
||||||
|
"description": "Broad requests that span multiple systems but still sound actionable.",
|
||||||
|
"aliases": ["broad", "misc", "cross-system"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "other",
|
||||||
|
"label": "Other",
|
||||||
|
"description": "Fallback tag for requests that do not match the current standardized catalog.",
|
||||||
|
"aliases": ["unknown", "unclear", "unsorted"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const repoRoot = path.resolve(__dirname, "..");
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
const kbRoot = path.join(repoRoot, "docs", "kb");
|
const kbRoot = path.join(repoRoot, "docs", "kb");
|
||||||
|
const requestTagCatalogPath = path.join(kbRoot, "tags.json");
|
||||||
const DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
const DEFAULT_DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
||||||
const DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-flash";
|
const DEFAULT_DEEPSEEK_MODEL = "deepseek-v4-flash";
|
||||||
const DEFAULT_PROVIDER = process.env.REQUEST_ANALYZER_PROVIDER
|
const DEFAULT_PROVIDER = process.env.REQUEST_ANALYZER_PROVIDER
|
||||||
|
|
@ -231,6 +232,48 @@ function uniqueStrings(values) {
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRequestTagLookupValue(value) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRequestTagCatalog() {
|
||||||
|
const payload = JSON.parse(await fs.readFile(requestTagCatalogPath, "utf8"));
|
||||||
|
const tags = Array.isArray(payload?.tags)
|
||||||
|
? payload.tags
|
||||||
|
.map((entry) => ({
|
||||||
|
id: String(entry?.id || "").trim(),
|
||||||
|
label: String(entry?.label || "").trim(),
|
||||||
|
aliases: Array.isArray(entry?.aliases) ? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean) : [],
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.id && entry.label)
|
||||||
|
: [];
|
||||||
|
if (tags.length === 0) {
|
||||||
|
throw new Error("Request tag catalog did not contain any valid tags.");
|
||||||
|
}
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequestTagLookup(tagDefinitions) {
|
||||||
|
return new Map(
|
||||||
|
(Array.isArray(tagDefinitions) ? tagDefinitions : []).flatMap((entry) => [
|
||||||
|
[normalizeRequestTagLookupValue(entry.label), entry.label],
|
||||||
|
...(Array.isArray(entry.aliases) ? entry.aliases.map((alias) => [normalizeRequestTagLookupValue(alias), entry.label]) : []),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequestTags(values, tagLookup, fallback = []) {
|
||||||
|
const normalized = uniqueStrings(Array.isArray(values) ? values : [])
|
||||||
|
.map((entry) => tagLookup.get(normalizeRequestTagLookupValue(entry)) || "")
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return uniqueStrings(normalized);
|
||||||
|
}
|
||||||
|
return uniqueStrings(Array.isArray(fallback) ? fallback : [])
|
||||||
|
.map((entry) => tagLookup.get(normalizeRequestTagLookupValue(entry)) || "")
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function clampConfidence(value) {
|
function clampConfidence(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed)) {
|
if (!Number.isFinite(parsed)) {
|
||||||
|
|
@ -264,7 +307,7 @@ function buildFallbackTitle(text, fallback = "Pending request") {
|
||||||
return firstSentence.length > 72 ? `${firstSentence.slice(0, 69).trim()}...` : firstSentence;
|
return firstSentence.length > 72 ? `${firstSentence.slice(0, 69).trim()}...` : firstSentence;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSystems = []) {
|
function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSystems = [], kb = null) {
|
||||||
const source = rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)
|
const source = rawItem && typeof rawItem === "object" && !Array.isArray(rawItem)
|
||||||
? rawItem
|
? rawItem
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -281,15 +324,21 @@ function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSyste
|
||||||
const primaryCategory = String(source.primaryCategory || source.category || "").trim()
|
const primaryCategory = String(source.primaryCategory || source.category || "").trim()
|
||||||
|| String(relevantSystems[0]?.name || "Unsorted");
|
|| String(relevantSystems[0]?.name || "Unsorted");
|
||||||
const affectedSystems = uniqueStrings(source.affectedSystems);
|
const affectedSystems = uniqueStrings(source.affectedSystems);
|
||||||
const defaultTags = uniqueStrings([
|
const defaultTags = [
|
||||||
primaryCategory,
|
primaryCategory,
|
||||||
...affectedSystems,
|
...affectedSystems,
|
||||||
]);
|
];
|
||||||
const tags = uniqueStrings(Array.isArray(source.tags) ? source.tags : defaultTags);
|
const tags = normalizeRequestTags(
|
||||||
|
Array.isArray(source.tags) ? source.tags : defaultTags,
|
||||||
|
kb?.requestTagLookup || new Map(),
|
||||||
|
defaultTags,
|
||||||
|
);
|
||||||
|
const reviewRationale = String(source.reviewRationale || source.reviewReason || source.notes || "").trim();
|
||||||
|
const reviewOptions = uniqueStrings(source.reviewOptions);
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
primaryCategory,
|
primaryCategory,
|
||||||
tags: tags.length > 0 ? tags : ["Unsorted"],
|
tags: tags.length > 0 ? tags : ["General"],
|
||||||
statusRecommendation: normalizeItemStatusRecommendation(source.statusRecommendation || source.status),
|
statusRecommendation: normalizeItemStatusRecommendation(source.statusRecommendation || source.status),
|
||||||
parsedInterpretation,
|
parsedInterpretation,
|
||||||
implementationApproach,
|
implementationApproach,
|
||||||
|
|
@ -298,11 +347,13 @@ function normalizeAnalysisItem(rawItem, index = 0, request = null, relevantSyste
|
||||||
problemType: normalizeProblemType(source.problemType),
|
problemType: normalizeProblemType(source.problemType),
|
||||||
rawExcerpt: String(source.rawExcerpt || "").trim(),
|
rawExcerpt: String(source.rawExcerpt || "").trim(),
|
||||||
confidence: clampConfidence(source.confidence),
|
confidence: clampConfidence(source.confidence),
|
||||||
|
reviewRationale,
|
||||||
|
reviewOptions,
|
||||||
notes: String(source.notes || "").trim(),
|
notes: String(source.notes || "").trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAnalysisResult(rawResult, request, relevantSystems) {
|
function normalizeAnalysisResult(rawResult, request, relevantSystems, kb = null) {
|
||||||
const source = rawResult && typeof rawResult === "object" && !Array.isArray(rawResult)
|
const source = rawResult && typeof rawResult === "object" && !Array.isArray(rawResult)
|
||||||
? rawResult
|
? rawResult
|
||||||
: (Array.isArray(rawResult) ? { items: rawResult } : null);
|
: (Array.isArray(rawResult) ? { items: rawResult } : null);
|
||||||
|
|
@ -310,7 +361,7 @@ function normalizeAnalysisResult(rawResult, request, relevantSystems) {
|
||||||
throw new Error("Model response was not a JSON object.");
|
throw new Error("Model response was not a JSON object.");
|
||||||
}
|
}
|
||||||
const items = (Array.isArray(source.items) ? source.items : [])
|
const items = (Array.isArray(source.items) ? source.items : [])
|
||||||
.map((item, index) => normalizeAnalysisItem(item, index, request, relevantSystems))
|
.map((item, index) => normalizeAnalysisItem(item, index, request, relevantSystems, kb))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
throw new Error("Model response did not contain any valid request items.");
|
throw new Error("Model response did not contain any valid request items.");
|
||||||
|
|
@ -336,6 +387,7 @@ async function loadKnowledgeBase() {
|
||||||
const requestSchemaPath = path.join(kbRoot, "request-analysis-schema.json");
|
const requestSchemaPath = path.join(kbRoot, "request-analysis-schema.json");
|
||||||
const systemsIndex = JSON.parse(await fs.readFile(systemsIndexPath, "utf8"));
|
const systemsIndex = JSON.parse(await fs.readFile(systemsIndexPath, "utf8"));
|
||||||
const requestSchema = JSON.parse(await fs.readFile(requestSchemaPath, "utf8"));
|
const requestSchema = JSON.parse(await fs.readFile(requestSchemaPath, "utf8"));
|
||||||
|
const requestTagDefinitions = await loadRequestTagCatalog();
|
||||||
const docsById = new Map();
|
const docsById = new Map();
|
||||||
for (const system of Array.isArray(systemsIndex.systems) ? systemsIndex.systems : []) {
|
for (const system of Array.isArray(systemsIndex.systems) ? systemsIndex.systems : []) {
|
||||||
const docPath = path.join(repoRoot, String(system.docPath || "").replace(/\//g, path.sep));
|
const docPath = path.join(repoRoot, String(system.docPath || "").replace(/\//g, path.sep));
|
||||||
|
|
@ -345,6 +397,8 @@ async function loadKnowledgeBase() {
|
||||||
return {
|
return {
|
||||||
systemsIndex,
|
systemsIndex,
|
||||||
requestSchema,
|
requestSchema,
|
||||||
|
requestTagDefinitions,
|
||||||
|
requestTagLookup: buildRequestTagLookup(requestTagDefinitions),
|
||||||
docsById,
|
docsById,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -400,8 +454,11 @@ function pickRelevantSystems(kb, requestText, limit = 4) {
|
||||||
return ranked.slice(0, limit);
|
return ranked.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPrompt(request, relevantSystems, schema) {
|
function buildPrompt(request, relevantSystems, schema, kb) {
|
||||||
const schemaSummary = JSON.stringify(schema, null, 2);
|
const schemaSummary = JSON.stringify(schema, null, 2);
|
||||||
|
const tagCatalogSummary = Array.isArray(kb?.requestTagDefinitions)
|
||||||
|
? kb.requestTagDefinitions.map((entry) => `- ${entry.label}: ${entry.description || ""}`.trim()).join("\n")
|
||||||
|
: "";
|
||||||
const systemDocs = relevantSystems.map(({ system, docText }) => {
|
const systemDocs = relevantSystems.map(({ system, docText }) => {
|
||||||
return [
|
return [
|
||||||
`System: ${system.name}`,
|
`System: ${system.name}`,
|
||||||
|
|
@ -419,6 +476,8 @@ function buildPrompt(request, relevantSystems, schema) {
|
||||||
"You are processing Worldshaper editor requests.",
|
"You are processing Worldshaper editor requests.",
|
||||||
"Split a submission into one or more atomic requests.",
|
"Split a submission into one or more atomic requests.",
|
||||||
"Ground your decisions in the provided KB systems only.",
|
"Ground your decisions in the provided KB systems only.",
|
||||||
|
"Use only the standardized tags listed in the provided tag catalog.",
|
||||||
|
"Do not expose or simulate hidden chain-of-thought. Provide short structured review rationale instead.",
|
||||||
"Return only valid JSON.",
|
"Return only valid JSON.",
|
||||||
"Do not wrap the JSON in markdown fences.",
|
"Do not wrap the JSON in markdown fences.",
|
||||||
"If you are unsure, lower confidence and use statusRecommendation = \"needs_review\".",
|
"If you are unsure, lower confidence and use statusRecommendation = \"needs_review\".",
|
||||||
|
|
@ -434,6 +493,9 @@ function buildPrompt(request, relevantSystems, schema) {
|
||||||
"Return JSON matching this schema:",
|
"Return JSON matching this schema:",
|
||||||
schemaSummary,
|
schemaSummary,
|
||||||
"",
|
"",
|
||||||
|
"Standardized tags you may use:",
|
||||||
|
tagCatalogSummary,
|
||||||
|
"",
|
||||||
"Relevant KB systems:",
|
"Relevant KB systems:",
|
||||||
systemDocs,
|
systemDocs,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
|
|
@ -599,9 +661,9 @@ async function analyzeRequest(config, kb, request) {
|
||||||
if (!config.dryRun) {
|
if (!config.dryRun) {
|
||||||
await markRequestProcessing(config, request);
|
await markRequestProcessing(config, request);
|
||||||
}
|
}
|
||||||
const prompt = buildPrompt(request, relevantSystems, kb.requestSchema);
|
const prompt = buildPrompt(request, relevantSystems, kb.requestSchema, kb);
|
||||||
const modelResult = await callModelApi(config, prompt);
|
const modelResult = await callModelApi(config, prompt);
|
||||||
const normalizedResult = normalizeAnalysisResult(modelResult, request, relevantSystems.map((entry) => entry.system));
|
const normalizedResult = normalizeAnalysisResult(modelResult, request, relevantSystems.map((entry) => entry.system), kb);
|
||||||
const action = shouldPromoteAnalysis(normalizedResult, config) ? "promote" : "review";
|
const action = shouldPromoteAnalysis(normalizedResult, config) ? "promote" : "review";
|
||||||
console.log(` Result: ${normalizedResult.items.length} item(s), action=${action}, confidence=${normalizedResult.confidence ?? "n/a"}`);
|
console.log(` Result: ${normalizedResult.items.length} item(s), action=${action}, confidence=${normalizedResult.confidence ?? "n/a"}`);
|
||||||
if (config.dryRun) {
|
if (config.dryRun) {
|
||||||
|
|
|
||||||
97
server.js
97
server.js
|
|
@ -11,6 +11,33 @@ const app = express();
|
||||||
const port = Number(process.env.PORT) || 5180;
|
const port = Number(process.env.PORT) || 5180;
|
||||||
const host = process.env.HOST || "0.0.0.0";
|
const host = process.env.HOST || "0.0.0.0";
|
||||||
const launcherAdminPassword = String(process.env.LAUNCHER_ADMIN_PASSWORD || "").trim();
|
const launcherAdminPassword = String(process.env.LAUNCHER_ADMIN_PASSWORD || "").trim();
|
||||||
|
const DEFAULT_REQUEST_TAG_DEFINITIONS = [
|
||||||
|
{ id: "launcher", label: "Launcher", aliases: ["launcher home", "home page", "landing page", "main page"] },
|
||||||
|
{ id: "request-board", label: "Request Board", aliases: ["requests", "request queue", "moderation", "request intake"] },
|
||||||
|
{ id: "chunks", label: "Chunks", aliases: ["chunk", "chunk loading", "chunk storage", "chunk overview"] },
|
||||||
|
{ id: "layers", label: "Layers", aliases: ["layer", "height layers", "z layers"] },
|
||||||
|
{ id: "tiling", label: "Tiling", aliases: ["tile", "tile editing", "tile painting", "brush"] },
|
||||||
|
{ id: "graphics-painter", label: "Graphics Painter", aliases: ["graphic painter", "tile art", "sprite painter", "art editor"] },
|
||||||
|
{ id: "rendering", label: "Rendering", aliases: ["renderer", "viewport", "pixi", "draw"] },
|
||||||
|
{ id: "animation", label: "Animation", aliases: ["animated", "timeline", "frame playback"] },
|
||||||
|
{ id: "content", label: "Content", aliases: ["assets", "catalog", "tiles", "sprites", "images"] },
|
||||||
|
{ id: "world-overview", label: "World Overview", aliases: ["overview", "world map", "bookmarks", "poi"] },
|
||||||
|
{ id: "windows", label: "Windows", aliases: ["windowing", "popout", "tool windows", "floating window"] },
|
||||||
|
{ id: "persistence", label: "Persistence", aliases: ["saving", "save pipeline", "history", "undo", "redo"] },
|
||||||
|
{ id: "performance", label: "Performance", aliases: ["optimization", "slow", "lag", "streaming"] },
|
||||||
|
{ id: "ui-workflow", label: "UI / Workflow", aliases: ["workflow", "ux", "ui", "quality of life", "qol", "tools", "tooling"] },
|
||||||
|
{ id: "worlds", label: "Worlds", aliases: ["world", "world bootstrap", "startup", "world systems"] },
|
||||||
|
{ id: "website", label: "Website", aliases: ["site", "web", "homepage"] },
|
||||||
|
{ id: "repo", label: "Repo", aliases: ["repository", "forgejo", "git"] },
|
||||||
|
{ id: "wiki", label: "Wiki", aliases: ["kb", "knowledge base", "docs", "documentation"] },
|
||||||
|
{ id: "polish", label: "Polish", aliases: ["cleanup", "fit and finish", "presentation"] },
|
||||||
|
{ id: "general", label: "General", aliases: ["broad", "misc", "cross-system"] },
|
||||||
|
{ id: "other", label: "Other", aliases: ["unknown", "unclear", "unsorted"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeRequestTagLookupValue(value) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function isLauncherAdminProtectionEnabled() {
|
function isLauncherAdminProtectionEnabled() {
|
||||||
return Boolean(launcherAdminPassword);
|
return Boolean(launcherAdminPassword);
|
||||||
|
|
@ -73,6 +100,7 @@ const dialogueNodeMetaPath = path.join(dataRoot, "dialogue_node_meta.json");
|
||||||
const editorSettingsPath = path.join(dataRoot, "editor_settings.json");
|
const editorSettingsPath = path.join(dataRoot, "editor_settings.json");
|
||||||
const launcherRequestsPath = path.join(dataRoot, "launcher_requests.json");
|
const launcherRequestsPath = path.join(dataRoot, "launcher_requests.json");
|
||||||
const requestAnalysisWorkerScriptPath = path.join(__dirname, "scripts", "request-analysis-worker.mjs");
|
const requestAnalysisWorkerScriptPath = path.join(__dirname, "scripts", "request-analysis-worker.mjs");
|
||||||
|
const requestTagCatalogPath = path.join(__dirname, "docs", "kb", "tags.json");
|
||||||
const requestAnalysisRunState = {
|
const requestAnalysisRunState = {
|
||||||
child: null,
|
child: null,
|
||||||
restartTimer: null,
|
restartTimer: null,
|
||||||
|
|
@ -81,6 +109,17 @@ const imagesCatalogPath = path.join(contentRoot, "images.json");
|
||||||
const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json");
|
const legacyTilesCatalogPath = path.join(contentRoot, "tiles.json");
|
||||||
const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json");
|
const legacySpritesCatalogPath = path.join(contentRoot, "sprites.json");
|
||||||
const recentSaveEvents = [];
|
const recentSaveEvents = [];
|
||||||
|
const REQUEST_TAG_DEFINITIONS = loadRequestTagCatalog();
|
||||||
|
const REQUEST_TAG_LABELS = REQUEST_TAG_DEFINITIONS.map((entry) => entry.label);
|
||||||
|
const REQUEST_TAG_LOOKUP = new Map(
|
||||||
|
REQUEST_TAG_DEFINITIONS.flatMap((entry) => {
|
||||||
|
const normalizedLabel = normalizeRequestTagLookupValue(entry.label);
|
||||||
|
return [
|
||||||
|
[normalizedLabel, entry.label],
|
||||||
|
...entry.aliases.map((alias) => [normalizeRequestTagLookupValue(alias), entry.label]),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
const DEFAULT_WORLDSHAPER_THEME_PRESET = "azure";
|
const DEFAULT_WORLDSHAPER_THEME_PRESET = "azure";
|
||||||
const WORLDSHAPER_THEME_PRESET_IDS = new Set(["azure", "verdant", "ember", "amethyst"]);
|
const WORLDSHAPER_THEME_PRESET_IDS = new Set(["azure", "verdant", "ember", "amethyst"]);
|
||||||
const LEGACY_LAUNCHER_REQUEST_MIGRATIONS = {
|
const LEGACY_LAUNCHER_REQUEST_MIGRATIONS = {
|
||||||
|
|
@ -417,8 +456,8 @@ function normalizeLauncherRequestStatus(value) {
|
||||||
|
|
||||||
function normalizeLauncherRequestTags(value) {
|
function normalizeLauncherRequestTags(value) {
|
||||||
return normalizeUniqueStringList(value, {
|
return normalizeUniqueStringList(value, {
|
||||||
normalizeValue: (entry) => String(entry || "").replace(/\s+/g, " ").trim(),
|
normalizeValue: (entry) => normalizeRequestTag(entry),
|
||||||
dedupeKey: (entry) => String(entry || "").replace(/\s+/g, " ").trim().toLowerCase(),
|
dedupeKey: (entry) => normalizeRequestTag(entry).toLowerCase(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,6 +484,14 @@ function normalizeLauncherRequestAnalysisStringList(value) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLauncherRequestAnalysisTags(value, fallback = []) {
|
||||||
|
const normalized = normalizeLauncherRequestTags(Array.isArray(value) ? value : []);
|
||||||
|
if (normalized.length > 0) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalizeLauncherRequestTags(Array.isArray(fallback) ? fallback : []);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLauncherRequestAnalysisItem(item, index = 0) {
|
function normalizeLauncherRequestAnalysisItem(item, index = 0) {
|
||||||
const source = item && typeof item === "object" && !Array.isArray(item)
|
const source = item && typeof item === "object" && !Array.isArray(item)
|
||||||
? item
|
? item
|
||||||
|
|
@ -455,9 +502,11 @@ function normalizeLauncherRequestAnalysisItem(item, index = 0) {
|
||||||
const fallbackTitle = `Analyzed request ${index + 1}`;
|
const fallbackTitle = `Analyzed request ${index + 1}`;
|
||||||
const title = String(source.title || "").trim() || fallbackTitle;
|
const title = String(source.title || "").trim() || fallbackTitle;
|
||||||
const primaryCategory = String(source.primaryCategory || source.category || "").trim() || "Unsorted";
|
const primaryCategory = String(source.primaryCategory || source.category || "").trim() || "Unsorted";
|
||||||
const tags = normalizeLauncherRequestAnalysisStringList(source.tags);
|
const tags = normalizeLauncherRequestAnalysisTags(source.tags, [primaryCategory]);
|
||||||
const parsedInterpretation = String(source.parsedInterpretation || source.summary || "").trim();
|
const parsedInterpretation = String(source.parsedInterpretation || source.summary || "").trim();
|
||||||
const implementationApproach = String(source.implementationApproach || source.implementationNotes || "").trim();
|
const implementationApproach = String(source.implementationApproach || source.implementationNotes || "").trim();
|
||||||
|
const reviewRationale = String(source.reviewRationale || source.reviewReason || "").trim();
|
||||||
|
const reviewOptions = normalizeLauncherRequestAnalysisStringList(source.reviewOptions);
|
||||||
const statusRecommendationRaw = String(source.statusRecommendation || source.status || "").trim().toLowerCase();
|
const statusRecommendationRaw = String(source.statusRecommendation || source.status || "").trim().toLowerCase();
|
||||||
const statusRecommendation = statusRecommendationRaw === "active"
|
const statusRecommendation = statusRecommendationRaw === "active"
|
||||||
|| statusRecommendationRaw === "duplicate"
|
|| statusRecommendationRaw === "duplicate"
|
||||||
|
|
@ -480,7 +529,7 @@ function normalizeLauncherRequestAnalysisItem(item, index = 0) {
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
primaryCategory,
|
primaryCategory,
|
||||||
tags,
|
tags: tags.length > 0 ? tags : ["General"],
|
||||||
statusRecommendation,
|
statusRecommendation,
|
||||||
parsedInterpretation,
|
parsedInterpretation,
|
||||||
implementationApproach,
|
implementationApproach,
|
||||||
|
|
@ -489,6 +538,8 @@ function normalizeLauncherRequestAnalysisItem(item, index = 0) {
|
||||||
problemType,
|
problemType,
|
||||||
rawExcerpt: String(source.rawExcerpt || "").trim(),
|
rawExcerpt: String(source.rawExcerpt || "").trim(),
|
||||||
confidence: normalizeLauncherRequestAnalysisConfidence(source.confidence),
|
confidence: normalizeLauncherRequestAnalysisConfidence(source.confidence),
|
||||||
|
reviewRationale,
|
||||||
|
reviewOptions,
|
||||||
notes: String(source.notes || "").trim(),
|
notes: String(source.notes || "").trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -2166,6 +2217,37 @@ function normalizeUniqueStringList(value, options = {}) {
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadRequestTagCatalog() {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(fs.readFileSync(requestTagCatalogPath, "utf8"));
|
||||||
|
const tags = Array.isArray(payload?.tags)
|
||||||
|
? payload.tags
|
||||||
|
.map((entry) => ({
|
||||||
|
id: String(entry?.id || "").trim(),
|
||||||
|
label: String(entry?.label || "").trim(),
|
||||||
|
aliases: Array.isArray(entry?.aliases)
|
||||||
|
? entry.aliases.map((alias) => String(alias || "").trim()).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.id && entry.label)
|
||||||
|
: [];
|
||||||
|
if (tags.length > 0) {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to built-in definitions when the KB file is unavailable.
|
||||||
|
}
|
||||||
|
return DEFAULT_REQUEST_TAG_DEFINITIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequestTag(value) {
|
||||||
|
const normalizedValue = normalizeRequestTagLookupValue(value);
|
||||||
|
if (!normalizedValue) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return REQUEST_TAG_LOOKUP.get(normalizedValue) || "";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTagList(value) {
|
function normalizeTagList(value) {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -2710,6 +2792,13 @@ app.get("/api/debug/recent-saves", (req, res) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/launcher-request-meta", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
allowedTags: REQUEST_TAG_LABELS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/api/launcher-requests", (_req, res) => {
|
app.get("/api/launcher-requests", (_req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(readLauncherRequestsPayload());
|
res.json(readLauncherRequestsPayload());
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ type LauncherRequest = {
|
||||||
problemType?: string;
|
problemType?: string;
|
||||||
rawExcerpt?: string;
|
rawExcerpt?: string;
|
||||||
confidence?: number | null;
|
confidence?: number | null;
|
||||||
|
reviewRationale?: string;
|
||||||
|
reviewOptions?: string[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
@ -60,6 +62,8 @@ type LauncherRequest = {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LauncherRequestAnalysisItem = NonNullable<NonNullable<LauncherRequest["analysis"]>["items"]>[number];
|
||||||
|
|
||||||
type LauncherRequestsPayload = {
|
type LauncherRequestsPayload = {
|
||||||
requests?: LauncherRequest[];
|
requests?: LauncherRequest[];
|
||||||
};
|
};
|
||||||
|
|
@ -95,6 +99,10 @@ type ProcessPendingPayload = {
|
||||||
pid?: number;
|
pid?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LauncherRequestMetaPayload = {
|
||||||
|
allowedTags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type AdminAuthPayload = {
|
type AdminAuthPayload = {
|
||||||
ok?: boolean;
|
ok?: boolean;
|
||||||
accessGranted?: boolean;
|
accessGranted?: boolean;
|
||||||
|
|
@ -148,6 +156,108 @@ function isAdminAccessError(error: unknown): boolean {
|
||||||
|| text.includes("admin access is not configured");
|
|| text.includes("admin access is not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest {
|
||||||
|
return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryAnalysisItem(requestEntry: LauncherRequest): LauncherRequestAnalysisItem | null {
|
||||||
|
const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis?.items : [];
|
||||||
|
return items.length > 0 ? items[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConfidence(value: number | null | undefined): string {
|
||||||
|
if (!Number.isFinite(Number(value))) {
|
||||||
|
return "Unscored";
|
||||||
|
}
|
||||||
|
return `${Math.round(Number(value) * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapePopupHtml(value: string): string {
|
||||||
|
return String(value || "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReviewDetailsPopup(requestEntry: LauncherRequest): void {
|
||||||
|
const popup = window.open("", `worldshaper-review-${requestEntry.id}`, "popup=yes,width=840,height=760,resizable=yes,scrollbars=yes");
|
||||||
|
if (!popup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items : [];
|
||||||
|
const renderedItems = items.map((item, index) => {
|
||||||
|
const reviewOptions = Array.isArray(item?.reviewOptions) ? item.reviewOptions : [];
|
||||||
|
return `
|
||||||
|
<section class="review-card">
|
||||||
|
<div class="review-kicker">Review Item ${index + 1}</div>
|
||||||
|
<h2>${escapePopupHtml(String(item?.title || `Request ${index + 1}`))}</h2>
|
||||||
|
<div class="review-meta">
|
||||||
|
<span>${escapePopupHtml(String(item?.primaryCategory || "Unsorted"))}</span>
|
||||||
|
<span>${escapePopupHtml(String(item?.statusRecommendation || "needs_review"))}</span>
|
||||||
|
<span>${escapePopupHtml(formatConfidence(item?.confidence))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="review-block">
|
||||||
|
<h3>Review Rationale</h3>
|
||||||
|
<p>${escapePopupHtml(String(item?.reviewRationale || "No structured review rationale was returned."))}</p>
|
||||||
|
</div>
|
||||||
|
<div class="review-block">
|
||||||
|
<h3>Parsed Interpretation</h3>
|
||||||
|
<p>${escapePopupHtml(String(item?.parsedInterpretation || ""))}</p>
|
||||||
|
</div>
|
||||||
|
<div class="review-block">
|
||||||
|
<h3>Implementation Approach</h3>
|
||||||
|
<p>${escapePopupHtml(String(item?.implementationApproach || ""))}</p>
|
||||||
|
</div>
|
||||||
|
<div class="review-block">
|
||||||
|
<h3>Possible Options</h3>
|
||||||
|
${reviewOptions.length > 0
|
||||||
|
? `<ul>${reviewOptions.map((option) => `<li>${escapePopupHtml(String(option || ""))}</li>`).join("")}</ul>`
|
||||||
|
: "<p>No structured options were returned.</p>"}
|
||||||
|
</div>
|
||||||
|
<div class="review-block">
|
||||||
|
<h3>Notes</h3>
|
||||||
|
<p>${escapePopupHtml(String(item?.notes || "No extra notes."))}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
popup.document.open();
|
||||||
|
popup.document.write(`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Worldshaper Review Details</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: Georgia, "Segoe UI", sans-serif; background: #08111f; color: #e8f2ff; }
|
||||||
|
main { max-width: 920px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; }
|
||||||
|
.hero { padding: 18px 20px; border: 1px solid #365782; border-radius: 14px; background: linear-gradient(180deg, rgba(17,32,63,.96), rgba(10,19,38,.98)); }
|
||||||
|
.hero h1 { margin: 0 0 8px; font-size: 28px; }
|
||||||
|
.hero p { margin: 0; color: #b8cfee; line-height: 1.6; white-space: pre-wrap; }
|
||||||
|
.review-card { padding: 18px 20px; border: 1px solid #365782; border-radius: 14px; background: rgba(17,32,63,.88); display: grid; gap: 12px; }
|
||||||
|
.review-kicker { color: #ffd166; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
|
||||||
|
.review-card h2, .review-block h3 { margin: 0; }
|
||||||
|
.review-meta { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.review-meta span { padding: 4px 8px; border: 1px solid #365782; border-radius: 999px; background: rgba(8,16,31,.75); font-size: 12px; }
|
||||||
|
.review-block { display: grid; gap: 6px; }
|
||||||
|
.review-block p, .review-block ul { margin: 0; color: #d7e7ff; line-height: 1.6; white-space: pre-wrap; }
|
||||||
|
.review-block ul { padding-left: 18px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>${escapePopupHtml(requestEntry.title)}</h1>
|
||||||
|
<p>${escapePopupHtml(requestEntry.sourceText)}</p>
|
||||||
|
</section>
|
||||||
|
${renderedItems || '<section class="review-card"><h2>No Review Items</h2><p>This request does not have structured review details yet.</p></section>'}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
popup.document.close();
|
||||||
|
}
|
||||||
|
|
||||||
function formatRequestTimestamp(value: string): string {
|
function formatRequestTimestamp(value: string): string {
|
||||||
const parsed = Date.parse(String(value || ""));
|
const parsed = Date.parse(String(value || ""));
|
||||||
if (!Number.isFinite(parsed)) {
|
if (!Number.isFinite(parsed)) {
|
||||||
|
|
@ -305,6 +415,7 @@ function WorldshaperLauncher() {
|
||||||
const [requestSubmitting, setRequestSubmitting] = useState(false);
|
const [requestSubmitting, setRequestSubmitting] = useState(false);
|
||||||
const [requestMutatingId, setRequestMutatingId] = useState("");
|
const [requestMutatingId, setRequestMutatingId] = useState("");
|
||||||
const [requestFilter, setRequestFilter] = useState("all");
|
const [requestFilter, setRequestFilter] = useState("all");
|
||||||
|
const [allowedRequestTags, setAllowedRequestTags] = useState<string[]>([]);
|
||||||
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
|
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
|
||||||
const [adminPanelOpen, setAdminPanelOpen] = useState(false);
|
const [adminPanelOpen, setAdminPanelOpen] = useState(false);
|
||||||
const [adminAccessGranted, setAdminAccessGranted] = useState(false);
|
const [adminAccessGranted, setAdminAccessGranted] = useState(false);
|
||||||
|
|
@ -312,6 +423,9 @@ function WorldshaperLauncher() {
|
||||||
const [adminPasswordDraft, setAdminPasswordDraft] = useState("");
|
const [adminPasswordDraft, setAdminPasswordDraft] = useState("");
|
||||||
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
|
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
|
||||||
const [adminPasswordError, setAdminPasswordError] = useState("");
|
const [adminPasswordError, setAdminPasswordError] = useState("");
|
||||||
|
const [selectedAdminRequestId, setSelectedAdminRequestId] = useState("");
|
||||||
|
const [adminEditorDraft, setAdminEditorDraft] = useState<LauncherRequest | null>(null);
|
||||||
|
const [adminSaving, setAdminSaving] = useState(false);
|
||||||
const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]);
|
const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]);
|
||||||
const [logsLoading, setLogsLoading] = useState(false);
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
const [logsError, setLogsError] = useState("");
|
const [logsError, setLogsError] = useState("");
|
||||||
|
|
@ -357,6 +471,15 @@ function WorldshaperLauncher() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRequestMeta(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payload = await fetchJsonOrThrow<LauncherRequestMetaPayload>("/api/launcher-request-meta");
|
||||||
|
setAllowedRequestTags(Array.isArray(payload.allowedTags) ? payload.allowedTags : []);
|
||||||
|
} catch {
|
||||||
|
setAllowedRequestTags([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRecentSaveEvents(): Promise<void> {
|
async function loadRecentSaveEvents(): Promise<void> {
|
||||||
setLogsLoading(true);
|
setLogsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -397,6 +520,7 @@ function WorldshaperLauncher() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadRequests();
|
void loadRequests();
|
||||||
|
void loadRequestMeta();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -412,6 +536,7 @@ function WorldshaperLauncher() {
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const refreshBoard = async (): Promise<void> => {
|
const refreshBoard = async (): Promise<void> => {
|
||||||
|
if (!adminPanelOpen) {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
|
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -420,6 +545,7 @@ function WorldshaperLauncher() {
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the current list visible during background refresh failures.
|
// Keep the current list visible during background refresh failures.
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
|
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -443,6 +569,26 @@ function WorldshaperLauncher() {
|
||||||
};
|
};
|
||||||
}, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]);
|
}, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!adminPanelOpen || !adminAccessGranted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requests.length === 0) {
|
||||||
|
setSelectedAdminRequestId("");
|
||||||
|
setAdminEditorDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedRequest = requests.find((entry) => entry.id === selectedAdminRequestId);
|
||||||
|
if (selectedRequest) {
|
||||||
|
if (!adminEditorDraft || adminEditorDraft.id !== selectedRequest.id) {
|
||||||
|
setAdminEditorDraft(cloneLauncherRequest(selectedRequest));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedAdminRequestId(requests[0].id);
|
||||||
|
setAdminEditorDraft(cloneLauncherRequest(requests[0]));
|
||||||
|
}, [adminPanelOpen, adminAccessGranted, requests, selectedAdminRequestId, adminEditorDraft]);
|
||||||
|
|
||||||
async function handleLaunch(): Promise<void> {
|
async function handleLaunch(): Promise<void> {
|
||||||
setError("");
|
setError("");
|
||||||
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
||||||
|
|
@ -539,6 +685,152 @@ function WorldshaperLauncher() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSelectAdminRequest(requestId: string): void {
|
||||||
|
const nextRequest = requests.find((entry) => entry.id === requestId);
|
||||||
|
setSelectedAdminRequestId(requestId);
|
||||||
|
setAdminEditorDraft(nextRequest ? cloneLauncherRequest(nextRequest) : null);
|
||||||
|
setAdminNotice("");
|
||||||
|
setAdminPasswordError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAdminDraft(updater: (current: LauncherRequest) => LauncherRequest): void {
|
||||||
|
setAdminEditorDraft((current) => (current ? updater(current) : current));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAdminDraftItem(
|
||||||
|
itemIndex: number,
|
||||||
|
updater: (item: LauncherRequestAnalysisItem) => LauncherRequestAnalysisItem,
|
||||||
|
): void {
|
||||||
|
updateAdminDraft((current) => {
|
||||||
|
const next = cloneLauncherRequest(current);
|
||||||
|
if (!next.analysis) {
|
||||||
|
next.analysis = {
|
||||||
|
state: "needs_review",
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const items = Array.isArray(next.analysis.items) ? [...next.analysis.items] : [];
|
||||||
|
const existingItem = items[itemIndex] || {};
|
||||||
|
items[itemIndex] = updater({
|
||||||
|
...existingItem,
|
||||||
|
tags: Array.isArray(existingItem.tags) ? [...existingItem.tags] : [],
|
||||||
|
affectedSystems: Array.isArray(existingItem.affectedSystems) ? [...existingItem.affectedSystems] : [],
|
||||||
|
affectedFiles: Array.isArray(existingItem.affectedFiles) ? [...existingItem.affectedFiles] : [],
|
||||||
|
reviewOptions: Array.isArray(existingItem.reviewOptions) ? [...existingItem.reviewOptions] : [],
|
||||||
|
});
|
||||||
|
next.analysis.items = items;
|
||||||
|
next.analysis.itemCount = items.length;
|
||||||
|
next.analysis.updatedAt = new Date().toISOString();
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAdminSavePayload(requestEntry: LauncherRequest): RequestInit {
|
||||||
|
return {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: buildAdminHeaders(adminPassword, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: requestEntry.title,
|
||||||
|
status: requestEntry.status,
|
||||||
|
category: requestEntry.category,
|
||||||
|
tags: requestEntry.tags,
|
||||||
|
sourceText: requestEntry.sourceText,
|
||||||
|
summary: requestEntry.summary,
|
||||||
|
implementationNotes: requestEntry.implementationNotes,
|
||||||
|
analysis: requestEntry.analysis,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAdminRequest(): Promise<void> {
|
||||||
|
if (!adminEditorDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAdminSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>(
|
||||||
|
`/api/launcher-requests/${encodeURIComponent(adminEditorDraft.id)}`,
|
||||||
|
buildAdminSavePayload(adminEditorDraft),
|
||||||
|
);
|
||||||
|
const nextRequests = Array.isArray(payload.requests) ? payload.requests : requests;
|
||||||
|
setRequests(nextRequests);
|
||||||
|
const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || payload.request || adminEditorDraft;
|
||||||
|
setAdminEditorDraft(cloneLauncherRequest(refreshed));
|
||||||
|
setAdminNotice(`Saved admin changes for "${adminEditorDraft.title}".`);
|
||||||
|
if (adminPanelOpen) {
|
||||||
|
void loadRecentSaveEvents();
|
||||||
|
}
|
||||||
|
} catch (nextError: unknown) {
|
||||||
|
if (isAdminAccessError(nextError)) {
|
||||||
|
setAdminAccessGranted(false);
|
||||||
|
setAdminPassword("");
|
||||||
|
setAdminPasswordError("Admin access expired. Enter the password again.");
|
||||||
|
}
|
||||||
|
setLogsError(String(nextError || "Failed to save admin changes."));
|
||||||
|
} finally {
|
||||||
|
setAdminSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApproveAdminRequest(): Promise<void> {
|
||||||
|
if (!adminEditorDraft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextDraft = cloneLauncherRequest(adminEditorDraft);
|
||||||
|
if (!nextDraft.analysis) {
|
||||||
|
setLogsError("This request has no analysis to approve yet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = Array.isArray(nextDraft.analysis.items) ? nextDraft.analysis.items : [];
|
||||||
|
nextDraft.analysis.items = items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
statusRecommendation: "active",
|
||||||
|
}));
|
||||||
|
nextDraft.analysis.state = "processed";
|
||||||
|
nextDraft.analysis.updatedAt = new Date().toISOString();
|
||||||
|
setAdminEditorDraft(nextDraft);
|
||||||
|
setAdminSaving(true);
|
||||||
|
try {
|
||||||
|
await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>(
|
||||||
|
`/api/launcher-requests/${encodeURIComponent(nextDraft.id)}`,
|
||||||
|
buildAdminSavePayload(nextDraft),
|
||||||
|
);
|
||||||
|
const promotePayload = await fetchJsonOrThrow<LauncherRequestsPayload>(
|
||||||
|
`/api/launcher-requests/${encodeURIComponent(nextDraft.id)}/process-analysis`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: buildAdminHeaders(adminPassword, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}),
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "promote",
|
||||||
|
analysis: nextDraft.analysis,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const nextRequests = Array.isArray(promotePayload.requests) ? promotePayload.requests : [];
|
||||||
|
setRequests(nextRequests);
|
||||||
|
const fallbackSelection = nextRequests[0] || null;
|
||||||
|
setSelectedAdminRequestId(fallbackSelection?.id || "");
|
||||||
|
setAdminEditorDraft(fallbackSelection ? cloneLauncherRequest(fallbackSelection) : null);
|
||||||
|
setAdminNotice(`Approved "${nextDraft.title}" and promoted its active request item${(nextDraft.analysis.items?.length || 0) === 1 ? "" : "s"}.`);
|
||||||
|
if (adminPanelOpen) {
|
||||||
|
void loadRecentSaveEvents();
|
||||||
|
}
|
||||||
|
} catch (nextError: unknown) {
|
||||||
|
if (isAdminAccessError(nextError)) {
|
||||||
|
setAdminAccessGranted(false);
|
||||||
|
setAdminPassword("");
|
||||||
|
setAdminPasswordError("Admin access expired. Enter the password again.");
|
||||||
|
}
|
||||||
|
setLogsError(String(nextError || "Failed to approve this request."));
|
||||||
|
} finally {
|
||||||
|
setAdminSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleToggleExpandedRequest(requestId: string): void {
|
function handleToggleExpandedRequest(requestId: string): void {
|
||||||
setExpandedRequestIds((current) => (
|
setExpandedRequestIds((current) => (
|
||||||
current.includes(requestId)
|
current.includes(requestId)
|
||||||
|
|
@ -622,12 +914,14 @@ function WorldshaperLauncher() {
|
||||||
const activeRequestCount = requests.filter((entry) => entry.status === "active").length;
|
const activeRequestCount = requests.filter((entry) => entry.status === "active").length;
|
||||||
const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length;
|
const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length;
|
||||||
const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length;
|
const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length;
|
||||||
const requestTags = Array.from(new Set(
|
const requestTags = (allowedRequestTags.length > 0
|
||||||
|
? allowedRequestTags
|
||||||
|
: Array.from(new Set(
|
||||||
requests
|
requests
|
||||||
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
|
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
|
||||||
.map((entry) => String(entry || "").trim())
|
.map((entry) => String(entry || "").trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
)).sort((a, b) => a.localeCompare(b));
|
))).sort((a, b) => a.localeCompare(b));
|
||||||
const filteredRequests = requests.filter((entry) => {
|
const filteredRequests = requests.filter((entry) => {
|
||||||
if (requestFilter === "status:pending") {
|
if (requestFilter === "status:pending") {
|
||||||
return entry.status === "pending";
|
return entry.status === "pending";
|
||||||
|
|
@ -881,27 +1175,40 @@ function WorldshaperLauncher() {
|
||||||
<section className="launcher-request-admin-card">
|
<section className="launcher-request-admin-card">
|
||||||
<div className="launcher-request-admin-card-head">
|
<div className="launcher-request-admin-card-head">
|
||||||
<h4 className="launcher-request-admin-card-title">Request Management</h4>
|
<h4 className="launcher-request-admin-card-title">Request Management</h4>
|
||||||
<div className="launcher-request-admin-card-hint">Delete actions live here now.</div>
|
<div className="launcher-request-admin-card-hint">Select a request to review or edit it.</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="launcher-request-admin-request-list">
|
<div className="launcher-request-admin-request-list">
|
||||||
{requests.map((requestEntry) => {
|
{requests.map((requestEntry) => {
|
||||||
const isMutating = requestMutatingId === requestEntry.id;
|
const isMutating = requestMutatingId === requestEntry.id;
|
||||||
const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state);
|
const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state);
|
||||||
|
const reviewItem = getPrimaryAnalysisItem(requestEntry);
|
||||||
|
const isSelected = requestEntry.id === selectedAdminRequestId;
|
||||||
return (
|
return (
|
||||||
<article key={`admin-${requestEntry.id}`} className="launcher-request-admin-request-row">
|
<article
|
||||||
|
key={`admin-${requestEntry.id}`}
|
||||||
|
className={`launcher-request-admin-request-row ${isSelected ? "is-selected" : ""}`}
|
||||||
|
onClick={() => handleSelectAdminRequest(requestEntry.id)}
|
||||||
|
>
|
||||||
<div className="launcher-request-admin-request-copy">
|
<div className="launcher-request-admin-request-copy">
|
||||||
<div className="launcher-request-admin-request-title">{requestEntry.title}</div>
|
<div className="launcher-request-admin-request-title">{requestEntry.title}</div>
|
||||||
<div className="launcher-request-admin-request-meta">
|
<div className="launcher-request-admin-request-meta">
|
||||||
<span>{formatRequestStatusLabel(requestEntry.status)}</span>
|
<span>{formatRequestStatusLabel(requestEntry.status)}</span>
|
||||||
<span>{requestEntry.category}</span>
|
<span>{requestEntry.category}</span>
|
||||||
<span>{analysisState}</span>
|
<span>{analysisState}</span>
|
||||||
|
<span>{formatConfidence(reviewItem?.confidence ?? requestEntry.analysis?.confidence)}</span>
|
||||||
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span>
|
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{reviewItem?.reviewRationale ? (
|
||||||
|
<div className="launcher-request-admin-request-rationale">{reviewItem.reviewRationale}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="launcher-request-delete-btn"
|
className="launcher-request-delete-btn"
|
||||||
onClick={() => void handleDeleteRequest(requestEntry)}
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void handleDeleteRequest(requestEntry);
|
||||||
|
}}
|
||||||
disabled={isMutating}
|
disabled={isMutating}
|
||||||
aria-label={`Delete ${requestEntry.title}`}
|
aria-label={`Delete ${requestEntry.title}`}
|
||||||
>
|
>
|
||||||
|
|
@ -912,6 +1219,253 @@ function WorldshaperLauncher() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className="launcher-request-admin-card launcher-request-admin-editor-card">
|
||||||
|
<div className="launcher-request-admin-card-head">
|
||||||
|
<h4 className="launcher-request-admin-card-title">Review Editor</h4>
|
||||||
|
<div className="launcher-request-admin-card-hint">Edit request fields, review details, and approval state.</div>
|
||||||
|
</div>
|
||||||
|
{!adminEditorDraft ? (
|
||||||
|
<div className="launcher-request-empty">Select a request from the list to review it.</div>
|
||||||
|
) : (
|
||||||
|
<div className="launcher-request-admin-editor">
|
||||||
|
<div className="launcher-request-admin-editor-grid">
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Title</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="launcher-request-filter-select"
|
||||||
|
value={adminEditorDraft.title}
|
||||||
|
onChange={(event) => updateAdminDraft((current) => ({ ...current, title: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Category</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="launcher-request-filter-select"
|
||||||
|
value={adminEditorDraft.category}
|
||||||
|
onChange={(event) => updateAdminDraft((current) => ({ ...current, category: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Original Submission</span>
|
||||||
|
<textarea
|
||||||
|
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||||
|
value={adminEditorDraft.sourceText}
|
||||||
|
onChange={(event) => updateAdminDraft((current) => ({ ...current, sourceText: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Request Summary</span>
|
||||||
|
<textarea
|
||||||
|
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||||
|
value={adminEditorDraft.summary}
|
||||||
|
onChange={(event) => updateAdminDraft((current) => ({ ...current, summary: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Request Implementation Notes</span>
|
||||||
|
<textarea
|
||||||
|
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||||
|
value={adminEditorDraft.implementationNotes}
|
||||||
|
onChange={(event) => updateAdminDraft((current) => ({ ...current, implementationNotes: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Request Tags</span>
|
||||||
|
<div className="launcher-request-admin-tag-grid">
|
||||||
|
{requestTags.map((tag) => {
|
||||||
|
const isActive = adminEditorDraft.tags.includes(tag);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`draft-tag-${tag}`}
|
||||||
|
type="button"
|
||||||
|
className={`launcher-request-tag launcher-request-admin-tag-toggle ${isActive ? "is-active" : ""}`}
|
||||||
|
onClick={() => updateAdminDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
tags: isActive
|
||||||
|
? current.tags.filter((entry) => entry !== tag)
|
||||||
|
: [...current.tags, tag].sort((left, right) => left.localeCompare(right)),
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{(adminEditorDraft.analysis?.items || []).map((item, itemIndex) => (
|
||||||
|
<section key={`analysis-item-${itemIndex}`} className="launcher-request-admin-analysis-item">
|
||||||
|
<div className="launcher-request-admin-analysis-head">
|
||||||
|
<div>
|
||||||
|
<div className="launcher-request-admin-kicker">Review Item {itemIndex + 1}</div>
|
||||||
|
<div className="launcher-request-admin-request-title">{item.title || `Request ${itemIndex + 1}`}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="launcher-secondary-btn"
|
||||||
|
onClick={() => openReviewDetailsPopup(adminEditorDraft)}
|
||||||
|
>
|
||||||
|
Open Review Popup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="launcher-request-admin-editor-grid">
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Item Title</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="launcher-request-filter-select"
|
||||||
|
value={String(item.title || "")}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, title: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Primary Category</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="launcher-request-filter-select"
|
||||||
|
value={String(item.primaryCategory || "")}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, primaryCategory: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Recommendation</span>
|
||||||
|
<select
|
||||||
|
className="launcher-request-filter-select"
|
||||||
|
value={String(item.statusRecommendation || "needs_review")}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, statusRecommendation: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="needs_review">Needs Review</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="blocked">Blocked</option>
|
||||||
|
<option value="duplicate">Duplicate</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Problem Type</span>
|
||||||
|
<select
|
||||||
|
className="launcher-request-filter-select"
|
||||||
|
value={String(item.problemType || "unknown")}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, problemType: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="feature">Feature</option>
|
||||||
|
<option value="bug">Bug</option>
|
||||||
|
<option value="workflow">Workflow</option>
|
||||||
|
<option value="performance">Performance</option>
|
||||||
|
<option value="ux">UX</option>
|
||||||
|
<option value="content">Content</option>
|
||||||
|
<option value="unknown">Unknown</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Confidence</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
className="launcher-request-filter-select"
|
||||||
|
value={Number.isFinite(Number(item.confidence)) ? String(item.confidence) : ""}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({
|
||||||
|
...current,
|
||||||
|
confidence: event.target.value === "" ? null : Number(event.target.value),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Standardized Tags</span>
|
||||||
|
<div className="launcher-request-admin-tag-grid">
|
||||||
|
{requestTags.map((tag) => {
|
||||||
|
const isActive = Array.isArray(item.tags) && item.tags.includes(tag);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`item-${itemIndex}-tag-${tag}`}
|
||||||
|
type="button"
|
||||||
|
className={`launcher-request-tag launcher-request-admin-tag-toggle ${isActive ? "is-active" : ""}`}
|
||||||
|
onClick={() => updateAdminDraftItem(itemIndex, (current) => {
|
||||||
|
const currentTags = Array.isArray(current.tags) ? current.tags : [];
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
tags: isActive
|
||||||
|
? currentTags.filter((entry) => entry !== tag)
|
||||||
|
: [...currentTags, tag].sort((left, right) => left.localeCompare(right)),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Review Rationale</span>
|
||||||
|
<textarea
|
||||||
|
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||||
|
value={String(item.reviewRationale || "")}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, reviewRationale: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Possible Options</span>
|
||||||
|
<textarea
|
||||||
|
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
|
||||||
|
value={Array.isArray(item.reviewOptions) ? item.reviewOptions.join("\n") : ""}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({
|
||||||
|
...current,
|
||||||
|
reviewOptions: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Parsed Interpretation</span>
|
||||||
|
<textarea
|
||||||
|
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||||
|
value={String(item.parsedInterpretation || "")}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, parsedInterpretation: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Implementation Approach</span>
|
||||||
|
<textarea
|
||||||
|
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||||
|
value={String(item.implementationApproach || "")}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, implementationApproach: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="launcher-request-admin-field">
|
||||||
|
<span className="launcher-request-filter-label">Notes</span>
|
||||||
|
<textarea
|
||||||
|
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
|
||||||
|
value={String(item.notes || "")}
|
||||||
|
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, notes: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
<div className="launcher-request-admin-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="launcher-primary-btn"
|
||||||
|
onClick={() => void handleSaveAdminRequest()}
|
||||||
|
disabled={adminSaving}
|
||||||
|
>
|
||||||
|
{adminSaving ? "Saving..." : "Save Review Changes"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="launcher-secondary-btn"
|
||||||
|
onClick={() => void handleApproveAdminRequest()}
|
||||||
|
disabled={adminSaving}
|
||||||
|
>
|
||||||
|
Approve Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
<section className="launcher-request-admin-card">
|
<section className="launcher-request-admin-card">
|
||||||
<div className="launcher-request-admin-card-head">
|
<div className="launcher-request-admin-card-head">
|
||||||
<h4 className="launcher-request-admin-card-title">Recent Logs</h4>
|
<h4 className="launcher-request-admin-card-title">Recent Logs</h4>
|
||||||
|
|
@ -997,6 +1551,7 @@ function WorldshaperLauncher() {
|
||||||
const isActiveRequest = requestEntry.status === "active";
|
const isActiveRequest = requestEntry.status === "active";
|
||||||
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
|
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
|
||||||
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
|
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
|
||||||
|
const reviewItem = getPrimaryAnalysisItem(requestEntry);
|
||||||
const analysisStateLabel = requestEntry.status === "pending"
|
const analysisStateLabel = requestEntry.status === "pending"
|
||||||
? formatAnalysisStateLabel(requestEntry.analysis?.state)
|
? formatAnalysisStateLabel(requestEntry.analysis?.state)
|
||||||
: "";
|
: "";
|
||||||
|
|
@ -1027,6 +1582,11 @@ function WorldshaperLauncher() {
|
||||||
<div className="launcher-request-entry-text">
|
<div className="launcher-request-entry-text">
|
||||||
{requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText}
|
{requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText}
|
||||||
</div>
|
</div>
|
||||||
|
{requestEntry.status === "pending" && reviewItem?.reviewRationale ? (
|
||||||
|
<div className="launcher-request-entry-review-note">
|
||||||
|
Review reason: {reviewItem.reviewRationale}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="launcher-request-entry-meta">
|
<div className="launcher-request-entry-meta">
|
||||||
{requestEntry.status === "pending" ? `${analysisStateLabel} | ` : ""}
|
{requestEntry.status === "pending" ? `${analysisStateLabel} | ` : ""}
|
||||||
{formatRequestTimestamp(requestEntry.updatedAt || requestEntry.createdAt)}
|
{formatRequestTimestamp(requestEntry.updatedAt || requestEntry.createdAt)}
|
||||||
|
|
|
||||||
|
|
@ -448,6 +448,12 @@ body {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-request-row.is-selected {
|
||||||
|
border-color: #63a6f2;
|
||||||
|
box-shadow: 0 0 0 1px rgba(99, 166, 242, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.launcher-request-admin-request-copy {
|
.launcher-request-admin-request-copy {
|
||||||
|
|
@ -456,6 +462,12 @@ body {
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-request-rationale {
|
||||||
|
color: #d7e7ff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.launcher-request-admin-request-title,
|
.launcher-request-admin-request-title,
|
||||||
.launcher-request-admin-log-title {
|
.launcher-request-admin-log-title {
|
||||||
color: #eef6ff;
|
color: #eef6ff;
|
||||||
|
|
@ -500,6 +512,65 @@ body {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-editor-card {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 58dvh;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-editor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-tag-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-tag-toggle {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-tag-toggle.is-active {
|
||||||
|
border-color: #4fa87e;
|
||||||
|
background: rgba(19, 73, 50, 0.88);
|
||||||
|
color: #b7f0d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-analysis-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #365782;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(8, 16, 31, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-analysis-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-textarea-sm {
|
||||||
|
min-height: 74px;
|
||||||
|
}
|
||||||
|
|
||||||
.launcher-request-composer-label {
|
.launcher-request-composer-label {
|
||||||
color: #d7e7ff;
|
color: #d7e7ff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
@ -680,6 +751,12 @@ body {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-request-entry-review-note {
|
||||||
|
color: #ffd5b0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
.launcher-request-entry-meta,
|
.launcher-request-entry-meta,
|
||||||
.launcher-request-empty {
|
.launcher-request-empty {
|
||||||
color: #9fb8e5;
|
color: #9fb8e5;
|
||||||
|
|
@ -1934,6 +2011,15 @@ button.danger:not(:disabled):hover {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-editor-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-request-admin-analysis-head {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
.launcher-request-filter {
|
.launcher-request-filter {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue