From eb3604e4fc044caa6949d8caea1f32ce300a5d58 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 29 May 2026 20:12:54 -0700 Subject: [PATCH 1/4] add Creative Ingredients Catalog: scraps, ingredients, sync, and bible backfill Postgres-backed catalog of canon "ingredients" (characters/places/objects) with pgvector embeddings, LLM-driven extraction from raw scraps, and a per-table sync_sequence federation gate (separate from the main pipeline sync) so a peer ahead on the catalog schema cannot corrupt an older peer. - DB: catalog_ingredients/_scraps/_ingredient_sources/_ingredient_refs with HNSW vector indexes and per-row sync_sequence triggers; schema gate added to PORTOS_SCHEMA_VERSIONS as `catalog`. - Bible backfill (server/scripts/migrateBibleToCatalog.js) promotes embedded universe canon into the catalog using SHA-256-derived deterministic ids over (universeId, kind, entry.id) so peers running the migration independently converge on the same row instead of minting divergent UUIDs and orphaning one side under LWW. Soft-deleted rows revive in place. updateUniverse gains { silent: true } so the boot walk doesn't fan out a recordUpdated per universe. - Sync envelope is size-capped to match create-side limits (rawText 2MB, payload 200KB, name/tag/ref_id caps, array lengths, portosMeta 4KB). /sync/apply rejects ahead-of-version peers with 412 and reports per-row {inserted/updated/skipped/failed} + errors[] instead of aborting the batch on one malformed row. - /sync accepts scalar OR per-kind cursors and rejects arrays; per-kind is needed because the four sync_sequence columns advance independently. - Extraction neutralizes triple-backtick fences in user-pasted scraps (zero-width joiner between the ticks) so a scrap containing a code fence can't close the LLM prompt's own fence. - listIngredients ORDER BY now references the user-query parameter by captured index instead of $1; type/tag filters no longer rank the filter literal. New staleEmbeddingModel filter + /embeddings/backfill { includeStale: true } so a provider/model switch can re-embed rows. - /ingredients/:id strips the 768-float embedding unless ?includeEmbedding=true. Detail/list pages use inline Delete? Yes/No rows instead of armed-delete. - CatalogIngest commit no longer drops every extracted field: payload is built from the flat bible-shaped draft minus control keys, with the description rubber-band fixed. - Storage barrels + READMEs updated for catalogDB, catalogSync, catalogValidation, catalogExtraction, catalogEvents, embeddings, and apiCatalog so future agents can find them via the catalog rule. --- .changelog/NEXT.md | 2 + PLAN.md | 13 + client/src/App.jsx | 6 + client/src/components/IngredientPicker.jsx | 278 ++++++++ client/src/components/Layout.jsx | 5 + .../src/components/settings/EmbeddingsTab.jsx | 202 ++++++ .../settings/SettingsTabsHeader.jsx | 1 + client/src/lib/bibleLimits.js | 1 + client/src/pages/Catalog.jsx | 339 ++++++++++ client/src/pages/CatalogIngest.jsx | 353 ++++++++++ client/src/pages/CatalogIngredient.jsx | 323 +++++++++ client/src/pages/Settings.jsx | 2 + client/src/services/api.js | 1 + client/src/services/apiCatalog.js | 82 +++ ...2026-05-29-creative-ingredients-catalog.md | 339 ++++++++++ server/index.js | 18 + server/lib/README.md | 1 + server/lib/catalogValidation.js | 202 ++++++ server/lib/db.js | 197 +++++- server/lib/index.js | 1 + server/lib/navManifest.js | 3 + server/lib/schemaVersions.js | 8 + server/lib/storyBible.js | 10 + server/lib/storyBible.test.js | 24 + server/lib/validation.js | 9 + server/routes/catalog.js | 281 ++++++++ server/routes/settings.js | 5 +- server/scripts/init-db.sql | 196 ++++++ server/scripts/migrateBibleToCatalog.js | 248 +++++++ server/services/catalogDB.js | 613 ++++++++++++++++++ server/services/catalogEvents.js | 13 + server/services/catalogExtraction.js | 124 ++++ server/services/catalogSync.js | 175 +++++ .../dataSync.pipelineUniverse.test.js | 12 + server/services/embeddings.js | 156 +++++ server/services/ollamaManager.js | 69 +- server/services/socket.js | 13 + server/services/universeBuilder.js | 13 +- 38 files changed, 4330 insertions(+), 8 deletions(-) create mode 100644 client/src/components/IngredientPicker.jsx create mode 100644 client/src/components/settings/EmbeddingsTab.jsx create mode 100644 client/src/pages/Catalog.jsx create mode 100644 client/src/pages/CatalogIngest.jsx create mode 100644 client/src/pages/CatalogIngredient.jsx create mode 100644 client/src/services/apiCatalog.js create mode 100644 docs/plans/2026-05-29-creative-ingredients-catalog.md create mode 100644 server/lib/catalogValidation.js create mode 100644 server/routes/catalog.js create mode 100644 server/scripts/migrateBibleToCatalog.js create mode 100644 server/services/catalogDB.js create mode 100644 server/services/catalogEvents.js create mode 100644 server/services/catalogExtraction.js create mode 100644 server/services/catalogSync.js create mode 100644 server/services/embeddings.js diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index 2e2cbd55d0..6794dd57c3 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -12,6 +12,8 @@ ## Added +- **[creative-ingredients-catalog-upgrade-path] Catalog deployment + upgrade story.** The bible→catalog backfill is now peer-aware: when a universe arrives via federated sync carrying an `ingredientId` that doesn't exist in the receiving install's catalog, the backfill creates the local catalog row WITH that explicit id instead of minting a new one — so the same logical character keeps the same catalog id across every install in the federation, even when the catalog rows weren't in the original sync payload. A new admin endpoint `POST /api/catalog/migration/rerun` (with `{ force: true }` to ignore the applied-marker) lets users recover from a stuck backfill or fix data on a peer. Catalog DDL ships in both `server/scripts/init-db.sql` (fresh installs via docker-entrypoint-initdb.d or `setup-db.js`/`db.sh setup-native`) AND `server/lib/db.js:ensureSchema()` (idempotent runtime upgrades — runs on every server boot), so existing installs pick up the catalog tables on the first restart post-pull without needing to re-run `init-db.sql`. +- **[creative-ingredients-catalog] New "Catalog" feature under Create.** A central, Postgres-backed store of typed creative ingredients — characters, places, objects, ideas, scenes, concepts. Paste any creative scrap (a one-line story spark, a scene snippet, a rough short-story draft) into `/catalog/ingest` and the server extracts candidate characters / places / objects via LLM, streams progress as a stage checklist, and shows a review screen where you check the ones to commit. The Catalog page (`/catalog`) lists every ingredient with type-chip filters, debounced search, and a one-click "+ New" inline form for the lighter idea/scene/concept types. Each ingredient has a detail page with source-scrap provenance and an "Appears in" panel that back-links to every universe / series / issue / work referencing it. Federation routes (`GET/POST /api/catalog/sync`) are wired and version-gated via the existing `PORTOS_SCHEMA_VERSIONS` contract (`catalog: 1`); the auto-orchestrator hookup ships in a follow-up. A boot-time migration backfilled 970 ingredients across 10 existing universes (94 characters, 140 places, 736 objects) so the catalog opens populated. **Vector embeddings are provider-agnostic** — a new Embeddings settings tab lets you pick Ollama or LM Studio + a 768-dim model (e.g. `nomic-embed-text`); embeddings auto-apply at ingest when configured and a `/api/catalog/embeddings/backfill` admin endpoint fills missing rows. Per-record sanitizer carries an `ingredientId` field through the round-trip so embedded universe canon stays linked to its catalog row. - **Voice coding agent can target a managed app.** When you dispatch a coding task by voice, you can now name a managed app ("fix the failing test in BookLoom") and the agent runs against that app's workspace instead of PortOS itself. The app name is fuzzy-matched, so "book loom", "BookLoom", and "bookloom" all resolve to the same app; if no app matches what you said, the agent refuses with a short list of valid names rather than silently running against PortOS. - **[voice-code-agent-status-query] Ask voice how a dispatched coding task is going.** A new voice query — "how's that coding task going?", "status of the agent", "is the agent still working?" — reports each in-flight voice-dispatched coding task with its current phase, target app, elapsed time, and a snippet of the task description. Until now you could only learn the outcome from the completion announcement; this is the mid-task check-in. Also available as "Coding agent status" in the command palette. diff --git a/PLAN.md b/PLAN.md index fc052c0006..fc7fd7874c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,6 +4,19 @@ For project goals, see [GOALS.md](./GOALS.md). For completed work, see [.changel ## Next Up +### Creative Ingredients Catalog — follow-up slices + +The catalog backend, schema, federation routes, ingest extraction service, and Catalog/Ingest/Detail UI all landed in one PR (see `docs/plans/2026-05-29-creative-ingredients-catalog.md` for the design record). Three Phase-7-onward slices remain: + +- [ ] [catalog-universe-builder-picker] **Add "Pick from Catalog" button to `client/src/pages/UniverseBuilder.jsx` character/place/object panels (~3736 LOC file).** When a catalog ingredient is picked, copy its `payload` into a new embedded canon entry with `ingredientId` stamped, AND post `linkCatalogIngredient(id, { refKind: 'universe', refId, role: 'canon-character'|'canon-place'|'canon-object' })`. Picker component lives at `client/src/components/IngredientPicker.jsx` (already built). Deferred from the initial PR because of the file size — needs targeted exploration of the canon-panel structure first (CanonTab / BibleTab around `UniverseBuilder.jsx:1835` + `:2945`). +- [ ] [catalog-pipeline-series-cast] **Add a "Cast (from Catalog)" section to `client/src/pages/PipelineSeries.jsx` that surfaces `listCatalogIngredientsForRef('series', seriesId)` and lets the user attach/detach via `IngredientPicker`.** Series records carry no embedded `characters[]` array, so this is purely additive — backed entirely by `catalog_ingredient_refs`. Same pattern applies to `client/src/pages/PipelineIssue.jsx` (refKind `'issue'`) and `client/src/pages/WritersRoom.jsx` work bibles (refKind `'work'`). +- [ ] [catalog-federation-orchestrator] **Wire the catalog into `server/services/syncOrchestrator.js` (or `server/services/sharing/peerSync.js`, whichever owns memory sync orchestration) so the existing `/api/catalog/sync` + `/api/catalog/sync/apply` routes auto-replicate between peers.** Today the routes exist and are version-gated via `PORTOS_SCHEMA_VERSIONS.catalog = 1`, but no scheduler pulls from peers. Mirror how memory sync is registered (it's the only existing Postgres-backed sync category). Outbound `portosMeta.schemaVersions.catalog` is already stamped server-side. **Universe/series sync today does NOT block on catalog sync** — universes carry their full embedded canon payloads, so a peer-pushed universe still arrives complete; the receiving peer's boot-time backfill walks the freshly-arrived universe and (now, post-`[catalog-universe-builder-picker]`'s peer-reconciliation fix) creates local catalog rows with the original IDs preserved. Direct catalog sync only matters for **orphan ingredients** (idea/scene/concept rows not attached to a universe) and for **post-`[catalog-universe-builder-picker]`** universe-ingredient picker links (the `catalog_ingredient_refs` rows themselves) so the "Appears in" panel populates across peers. +- [ ] [catalog-ddl-drift-test] **Add a server test that diffs the catalog DDL between `server/scripts/init-db.sql` and the catalog block in `server/lib/db.js:ensureSchema()`.** Both files now duplicate the four table definitions + indexes + trigger functions; a future PR that updates one without the other will leave fresh installs and upgrading installs with different schemas. Lift the catalog DDL into a shared constant array consumed by both, or write a smoke test that parses both files and compares the relevant statements. Same risk exists for the `memories`/`memory_links` DDL but has been tolerated since the memory system landed; catalog inherits it. Surfaced when the user asked about Postgres schema upgrade management (2026-05-29). +- [ ] [catalog-bundled-universe-push] **Bundle catalog ingredients into universe peer-push payloads.** When peer A pushes a universe to peer B, the push should include the `catalog_ingredients` rows referenced by that universe's embedded canon plus the `catalog_ingredient_refs` rows linking universe→ingredient. Today the embedded canon payloads still replicate (so universes work end-to-end across peers), but the `tags`, `embedding`, `payload.summary`-style enrichments that live ONLY in the catalog row do not — they get re-derived from the embedded entry on the receiver's first backfill, which is a strictly-lossy view. Mirror the `bundleAdditionalKinds` pattern in `server/services/sharing/peerSync.js`. Schema-version gating already covers the new bundle: `RECORD_KIND_SCHEMA_CATEGORIES.universe` would need `['universes', 'catalog']` appended OR the bundling can rely on `cat-ingredient` arriving as its own already-gated kind in the same push. Pairs with `[catalog-federation-orchestrator]`. +- [ ] [catalog-extraction-idea-scene-concept] **Extend `server/services/catalogExtraction.js` to also extract idea / scene / concept ingredients from a scrap.** Current extractor reuses `extractBible` for character/place/object only (the three storyBible-shaped types). For the other three, ship a single LLM JSON-mode call with a custom prompt; surface the new stages on `catalog:extract:progress`. Until this lands, ideas/scenes/concepts are created manually via the Catalog list's "+ New" button. +- [ ] [catalog-tests] **Tests for the catalog stack.** `server/services/catalogDB.test.js` (CRUD round-trip — bootstrap follows `server/services/memoryDB.test.js`), `catalogExtraction.test.js` (mock aiToolkit, assert Zod conformance), `catalogSync.test.js` (envelope round-trip + LWW), `server/lib/catalogValidation.test.js` (Zod boundary tests), `server/scripts/migrateBibleToCatalog.test.js` (idempotency), `client/src/pages/Catalog.test.jsx`, `client/src/components/IngredientPicker.test.jsx`. +- [ ] [catalog-writers-room-version-refs] **Phase 9 (deferred at design time): capture catalog ingredient ids per Writers Room draft version.** Extend the draft save path in `server/services/writersRoom/local.js` to accept `referencedIngredientIds[]`. Add `scanProseForIngredientRefs(text, { universeId?, seriesId?, workId? })` to `catalogExtraction.js` (substring match scoped to refs linked to the target). UI: render referenced ingredients on each version-history chip in `client/src/pages/WritersRoom.jsx`. + _Batch-cleared 2026-05-25: 23 Next Up items shipped together via parallel sub-agents (env-file helper, formatBytes migration, lazy voice UI text, voice long-term-memory routing, voice tool expansion + `ui_describe_visually`, three phosphene generate_ltx2.py hardenings, the peer-sync snapshot-coverage P1 refactor + ephemeralize-then-delete, per-record tombstone-ack clamp, MediaLightbox→MediaImage, global vitest peer mock, videoHistory sync category, reverse-sub UI broadcast, palette Health disambiguation, insertXxxWithId resurrection side-effects, importer tombstone detection, TUI finish-rejection note, mortalLoom errno widen, VideoGen stale-model toast, Universes sidebar grandchildren). See `git log` + `.changelog/NEXT.md`._ - [ ] [mediacard-use-mediaimage-for-syncing-assets] **`MediaCard.jsx` grid thumbnails still use a raw ``.** Same peer-sync placeholder/live-swap gap that `[peer-sync-medialightbox-use-mediaimage-for-syncing-assets]` fixed for the lightbox — `client/src/components/media/MediaCard.jsx` (~line 42) doesn't get the "Syncing" placeholder or the `peerSync:asset-arrived` atomic swap. Swap the raw `` for `MediaImage`. Surfaced by that item's cross-check during the batch-clear (2026-05-25); was outside its stated scope. diff --git a/client/src/App.jsx b/client/src/App.jsx index 9de5f8d467..e642116ae5 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -43,6 +43,9 @@ const MediaModels = lazyWithReload(() => import('./pages/MediaModels')); const Loras = lazyWithReload(() => import('./pages/Loras')); const UniverseBuilder = lazyWithReload(() => import('./pages/UniverseBuilder')); const Universes = lazyWithReload(() => import('./pages/Universes')); +const Catalog = lazyWithReload(() => import('./pages/Catalog')); +const CatalogIngest = lazyWithReload(() => import('./pages/CatalogIngest')); +const CatalogIngredient = lazyWithReload(() => import('./pages/CatalogIngredient')); const VideoTimeline = lazyWithReload(() => import('./pages/VideoTimeline')); const VideoTimelineEditor = lazyWithReload(() => import('./pages/VideoTimelineEditor')); const CreativeDirector = lazyWithReload(() => import('./pages/CreativeDirector')); @@ -251,6 +254,9 @@ export default function App() { at `/universes/:universeId`; `new` is the create-mode sentinel (UniverseBuilder treats it as no-id → blank draft). Universe ids are UUIDs, so `new` can never collide with a real record. */} + } /> + } /> + } /> } /> } /> } /> diff --git a/client/src/components/IngredientPicker.jsx b/client/src/components/IngredientPicker.jsx new file mode 100644 index 0000000000..cf4d8a625d --- /dev/null +++ b/client/src/components/IngredientPicker.jsx @@ -0,0 +1,278 @@ +/** + * IngredientPicker — modal for attaching catalog ingredients to a parent + * record (universe, series, issue, writers-room). + * + * Built on top of the shared `ui/Modal` chrome so Esc / backdrop / portal + * stacking match every other modal in the app. + * + * Props: + * open — visibility flag. + * onClose — fires on Esc, backdrop click, or the X button. + * onSelect — fires with the chosen ingredient when `multi` is false, + * or with an array of chosen ingredients when `multi` is + * true (user hit "Add Selected"). + * type — optional `'character' | 'place' | ...` to scope the search. + * multi — checkbox-mode toggle; defaults to single-click select. + * excludeIds — ids to hide from the result list (already-attached set). + * refKind/refId — currently unused by this component, but accepted so + * callers can wire up an "Already attached" section without + * the API surface changing later. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Search, Plus, Loader2, X, Sparkles } from 'lucide-react'; +import Modal from './ui/Modal'; +import { listCatalogIngredients } from '../services/apiCatalog'; + +const TYPE_BADGE = { + character: 'bg-blue-500/20 text-blue-300 border-blue-500/40', + place: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40', + object: 'bg-amber-500/20 text-amber-300 border-amber-500/40', + idea: 'bg-purple-500/20 text-purple-300 border-purple-500/40', + scene: 'bg-pink-500/20 text-pink-300 border-pink-500/40', + concept: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40', +}; + +function snippet(payload) { + if (!payload || typeof payload !== 'object') return ''; + const text = String(payload.description || payload.summary || payload.notes || '').trim().replace(/\s+/g, ' '); + if (text.length <= 140) return text; + return `${text.slice(0, 137)}…`; +} + +export default function IngredientPicker({ + open, + onClose, + onSelect, + type, + multi = false, + excludeIds = [], +}) { + const [searchInput, setSearchInput] = useState(''); + const [q, setQ] = useState(''); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedIds, setSelectedIds] = useState(() => new Set()); + // Track in-flight fetch generation so a late response from a stale query + // can't overwrite the current results. + const generationRef = useRef(0); + + // Debounce search input (300ms) before pinning to `q`. + useEffect(() => { + if (!open) return undefined; + const t = setTimeout(() => setQ(searchInput.trim()), 300); + return () => clearTimeout(t); + }, [searchInput, open]); + + // Reset transient state every time the modal opens — single-shot pickers + // should never inherit yesterday's checkboxes. Clear `items` too so the + // first frame of a new open doesn't flash stale results before the fetch + // resolves (especially visible when `type` changes between opens). + useEffect(() => { + if (!open) return; + setSearchInput(''); + setQ(''); + setSelectedIds(new Set()); + setItems([]); + }, [open]); + + // Fetch results. Refetch on q / type changes while open. + useEffect(() => { + if (!open) return undefined; + const gen = ++generationRef.current; + setLoading(true); + listCatalogIngredients({ + type: type || undefined, + q: q || undefined, + limit: 50, + silent: true, + }) + .then((data) => { + if (gen !== generationRef.current) return; + setItems(Array.isArray(data?.items) ? data.items : []); + setLoading(false); + }) + .catch(() => { + if (gen !== generationRef.current) return; + setItems([]); + setLoading(false); + }); + return undefined; + }, [open, q, type]); + + // Exclusion set lookup — Set is O(1), array .includes is O(n) per row. + const excludeSet = useMemo(() => new Set(excludeIds || []), [excludeIds]); + const filtered = useMemo( + () => items.filter((it) => !excludeSet.has(it.id)), + [items, excludeSet], + ); + + const toggleSelected = (id) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + }; + + const handleSingleSelect = (it) => { + onSelect?.(it); + onClose?.(); + }; + + const handleAddSelected = () => { + const picked = filtered.filter((it) => selectedIds.has(it.id)); + if (picked.length === 0) return; + onSelect?.(picked); + onClose?.(); + }; + + return ( + +
+
+
+ +
+ +
+
+
+
+ +
+ {loading ? ( +
+
+ ) : filtered.length === 0 ? ( +
+

No matching ingredients.

+ +
+ ) : ( +
    + {filtered.map((it) => { + const badge = TYPE_BADGE[it.type] || 'bg-gray-500/20 text-gray-300 border-gray-500/40'; + const checked = selectedIds.has(it.id); + const rowBase = 'w-full text-left p-3 rounded border bg-port-bg/40 transition-colors'; + const rowClass = multi + ? `${rowBase} ${checked ? 'border-port-accent' : 'border-port-border hover:border-gray-500'}` + : `${rowBase} border-port-border hover:border-port-accent`; + const inner = ( + <> +
    +
    + {multi && ( + toggleSelected(it.id)} + onClick={(e) => e.stopPropagation()} + className="accent-port-accent" + aria-label={`Select ${it.name || it.id}`} + /> + )} + + {it.name || '(untitled)'} + +
    + + {it.type} + +
    + {snippet(it.payload) && ( +

    {snippet(it.payload)}

    + )} + {Array.isArray(it.tags) && it.tags.length > 0 && ( +
    + {it.tags.slice(0, 5).map((tag) => ( + + {tag} + + ))} +
    + )} + + ); + return ( +
  • + {multi ? ( +
    toggleSelected(it.id)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelected(it.id); } }} + className={`${rowClass} cursor-pointer`}> + {inner} +
    + ) : ( + + )} +
  • + ); + })} +
+ )} +
+ + {multi && ( +
+ {selectedIds.size} selected +
+ + +
+
+ )} +
+ ); +} diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 38b22ba6c4..6e419153cd 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -196,6 +196,7 @@ const navItems = [ icon: Sparkles, defaultTo: '/media', children: [ + { to: '/catalog', label: 'Catalog', icon: Sparkles }, { to: '/importer', label: 'Importer', icon: FileInput }, { to: '/media', label: 'Media Gen', icon: Layers }, { to: '/pipeline', label: 'Series Pipeline', icon: WorkflowIcon, dynamic: 'pipelineSeries' }, @@ -1019,6 +1020,10 @@ export default function Layout() { location.pathname === '/ask' || location.pathname.startsWith('/ask/') || location.pathname.startsWith('/calendar') || + // Only the Catalog DETAIL editor (/catalog/{type}/{id}) and the + // Ingest page (/catalog/ingest) are full-width — they own their + // own scroll. The /catalog list/index page stays scrolling-default. + location.pathname.startsWith('/catalog/') || location.pathname.startsWith('/cos') || location.pathname.startsWith('/brain') || location.pathname.startsWith('/digital-twin') || diff --git a/client/src/components/settings/EmbeddingsTab.jsx b/client/src/components/settings/EmbeddingsTab.jsx new file mode 100644 index 0000000000..5a9042222e --- /dev/null +++ b/client/src/components/settings/EmbeddingsTab.jsx @@ -0,0 +1,202 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Boxes, RefreshCw, AlertTriangle, Check } from 'lucide-react'; +import toast from '../ui/Toast'; +import { getSettings, updateSettings } from '../../services/apiSystem'; +import { getLocalLlmStatus } from '../../services/apiLocalLlm'; + +// Provider-agnostic embeddings configuration. Powers the creative catalog's +// semantic search + the memory system's vector storage. Vector dim is pinned +// to 768 (matches the live `vector(768)` columns); the dropdown calls out +// known 768-dim models so the user picks one that won't get rejected. + +const PROVIDERS = [ + { id: 'none', label: 'Disabled', description: "Don't embed at ingest. Rows persist; semantic search returns empty until you backfill." }, + { id: 'ollama', label: 'Ollama', description: 'Use a locally installed Ollama embedding model.' }, + { id: 'lmstudio', label: 'LM Studio', description: 'Use an LM Studio embedding model.' }, +]; + +// 768-dim models we know about. Used as the recommendation hint in the +// dropdown — the user can still type any other model name. +const KNOWN_768_DIM = { + ollama: ['nomic-embed-text', 'snowflake-arctic-embed:s'], + lmstudio: ['nomic-ai/nomic-embed-text-v1.5-GGUF', 'text-embedding-nomic-embed-text-v1.5'], +}; + +const EMBEDDING_HINT_RE = /embed|bge|nomic|mxbai|gte|e5|arctic/i; + +export default function EmbeddingsTab() { + const [provider, setProvider] = useState('none'); + const [model, setModel] = useState(''); + const [saved, setSaved] = useState({ provider: 'none', model: '' }); + const [models, setModels] = useState({ ollama: [], lmstudio: [] }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [modelsLoading, setModelsLoading] = useState(false); + + const refreshModels = async () => { + setModelsLoading(true); + const status = await getLocalLlmStatus({ silent: true }).catch(() => null); + setModels({ + ollama: (status?.ollama?.installedModels || []).map((m) => m.id || m.name), + lmstudio: (status?.lmstudio?.installedModels || []).map((m) => m.id || m.name), + }); + setModelsLoading(false); + }; + + useEffect(() => { + (async () => { + const settings = await getSettings({ silent: true }).catch(() => null); + const cfg = settings?.embeddings || {}; + setProvider(cfg.provider || 'none'); + setModel(cfg.model || ''); + setSaved({ provider: cfg.provider || 'none', model: cfg.model || '' }); + setLoading(false); + refreshModels(); + })(); + }, []); + + const candidateModels = useMemo(() => { + if (provider === 'none') return []; + const installed = models[provider] || []; + const filtered = installed.filter((id) => EMBEDDING_HINT_RE.test(id)); + const known = KNOWN_768_DIM[provider] || []; + // Combine, dedupe, preserve order: filtered installed first, then known not-yet-installed. + const seen = new Set(); + const out = []; + for (const m of [...filtered, ...known]) { + if (!seen.has(m)) { seen.add(m); out.push(m); } + } + return out; + }, [provider, models]); + + const dirty = provider !== saved.provider || (model || '') !== (saved.model || ''); + + const handleSave = async () => { + if (!dirty || saving) return; + setSaving(true); + await updateSettings({ + embeddings: { provider, model: model.trim() || null }, + }).then(() => { + setSaved({ provider, model: model.trim() || '' }); + toast.success('Embeddings settings saved'); + }).catch((err) => { + toast.error(`Save failed: ${err?.message || 'unknown error'}`); + }).finally(() => setSaving(false)); + }; + + if (loading) { + return
Loading…
; + } + + return ( +
+
+ +

Embeddings

+
+ +

+ Vector embeddings power semantic search across the creative catalog (Characters, Ideas, + Scenes, …) and the memory system. PortOS expects 768-dimensional vectors; + pick a 768-dim model below or your saves will be rejected. +

+ + {/* Provider picker */} +
+ + +

+ {PROVIDERS.find((p) => p.id === provider)?.description} +

+
+ + {/* Model picker — hidden when provider is 'none' */} + {provider !== 'none' && ( +
+
+ + +
+ setModel(e.target.value)} + placeholder="nomic-embed-text" + className="w-full bg-port-bg border border-port-border rounded px-3 py-2 text-sm text-white" + /> + + {candidateModels.map((m) => ( + + {candidateModels.length === 0 && !modelsLoading && ( +
+ + + No installed embedding models detected on {provider}. Install one in Local LLMs + (e.g. nomic-embed-text on Ollama). + +
+ )} + {(models[provider] || []).length > 0 && !EMBEDDING_HINT_RE.test(model) && model && ( +
+ + + {model} doesn't look like an embedding model. Embeddings must be + 768-dim; non-embedding models will fail at ingest time. + +
+ )} +
+ )} + +
+ + {!dirty && !loading && ( + + Saved: {saved.provider === 'none' ? 'Disabled' : `${saved.provider}${saved.model ? ` · ${saved.model}` : ''}`} + + )} +
+
+ ); +} diff --git a/client/src/components/settings/SettingsTabsHeader.jsx b/client/src/components/settings/SettingsTabsHeader.jsx index 20bd2c2ecc..8c84bcbae1 100644 --- a/client/src/components/settings/SettingsTabsHeader.jsx +++ b/client/src/components/settings/SettingsTabsHeader.jsx @@ -13,6 +13,7 @@ const TABS = [ { id: 'autofixer', label: 'Autofixer', to: '/settings/autofixer' }, { id: 'backup', label: 'Backup', to: '/settings/backup' }, { id: 'database', label: 'Database', to: '/settings/database' }, + { id: 'embeddings', label: 'Embeddings', to: '/settings/embeddings' }, { id: 'general', label: 'General', to: '/settings/general' }, { id: 'local-llm', label: 'Local LLMs', to: '/settings/local-llm' }, { id: 'mortalloom', label: 'MortalLoom', to: '/settings/mortalloom' }, diff --git a/client/src/lib/bibleLimits.js b/client/src/lib/bibleLimits.js index 3c126d3f9a..7ef9453bc5 100644 --- a/client/src/lib/bibleLimits.js +++ b/client/src/lib/bibleLimits.js @@ -65,4 +65,5 @@ export const BIBLE_LIMITS = Object.freeze({ TAGS_PER_ENTRY_MAX: 12, SOURCE_SERIES_ID_MAX: 64, VOICE_ID_MAX: 200, + INGREDIENT_ID_MAX: 64, }); diff --git a/client/src/pages/Catalog.jsx b/client/src/pages/Catalog.jsx new file mode 100644 index 0000000000..8c22bfb135 --- /dev/null +++ b/client/src/pages/Catalog.jsx @@ -0,0 +1,339 @@ +/** + * Catalog page — index of Creative Ingredients. + * + * Lists every ingredient (character/place/object/idea/scene/concept) the user + * has captured into the catalog. Type chips along the top filter by kind and + * show the per-type count from `/api/catalog/stats`. The "+ New" inline form + * mirrors the Pipeline series-create pattern; "Ingest" links to the paste-and- + * extract page. Delete uses an armed two-click confirm (no window.confirm). + */ + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { Sparkles, Plus, Search, FileInput, Trash2, Loader2 } from 'lucide-react'; +import toast from '../components/ui/Toast'; +import { + listCatalogIngredients, + createCatalogIngredient, + deleteCatalogIngredient, + getCatalogStats, +} from '../services/apiCatalog'; + +const TYPES = [ + { id: 'character', label: 'Character', color: 'bg-blue-500/20 text-blue-300 border-blue-500/40' }, + { id: 'place', label: 'Place', color: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/40' }, + { id: 'object', label: 'Object', color: 'bg-amber-500/20 text-amber-300 border-amber-500/40' }, + { id: 'idea', label: 'Idea', color: 'bg-purple-500/20 text-purple-300 border-purple-500/40' }, + { id: 'scene', label: 'Scene', color: 'bg-pink-500/20 text-pink-300 border-pink-500/40' }, + { id: 'concept', label: 'Concept', color: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40' }, +]; + +const TYPE_BY_ID = Object.fromEntries(TYPES.map((t) => [t.id, t])); + +// Pull a short snippet from the type-specific payload — first hit wins +// (description → summary → notes), trimmed and ellipsised to ~120 chars. +function payloadSnippet(payload) { + if (!payload || typeof payload !== 'object') return ''; + const raw = payload.description || payload.summary || payload.notes || ''; + const text = String(raw).trim().replace(/\s+/g, ' '); + if (text.length <= 120) return text; + return `${text.slice(0, 117)}…`; +} + +function TypeBadge({ type }) { + const meta = TYPE_BY_ID[type]; + if (!meta) return null; + return ( + + {meta.label} + + ); +} + +export default function Catalog() { + const [items, setItems] = useState([]); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedType, setSelectedType] = useState(''); + // Two-stage search: `searchInput` is what the user is typing; `q` is the + // debounced value that actually drives the list fetch. 300ms gap. + const [searchInput, setSearchInput] = useState(''); + const [q, setQ] = useState(''); + // Inline create form + const [showForm, setShowForm] = useState(false); + const [form, setForm] = useState({ type: 'character', name: '' }); + const [creating, setCreating] = useState(false); + // Armed-row id for two-click delete (no window.confirm). + const [armedId, setArmedId] = useState(null); + + useEffect(() => { + const t = setTimeout(() => setQ(searchInput.trim()), 300); + return () => clearTimeout(t); + }, [searchInput]); + + const loadStats = useCallback(() => { + getCatalogStats({ silent: true }) + .then((s) => setStats(s || null)) + .catch(() => {}); + }, []); + + const loadItems = useCallback(() => { + let cancelled = false; + setLoading(true); + listCatalogIngredients({ + type: selectedType || undefined, + q: q || undefined, + limit: 200, + silent: true, + }) + .then((data) => { + if (cancelled) return; + setItems(Array.isArray(data?.items) ? data.items : []); + setLoading(false); + }) + .catch((err) => { + if (cancelled) return; + toast.error(err?.message || 'Failed to load catalog'); + setLoading(false); + }); + return () => { cancelled = true; }; + }, [selectedType, q]); + + useEffect(() => loadItems(), [loadItems]); + useEffect(() => loadStats(), [loadStats]); + + const totalCount = stats?.total ?? items.length; + const countForType = (id) => stats?.byType?.[id] || 0; + + const handleCreate = async (e) => { + e.preventDefault(); + const name = form.name.trim(); + if (!name) return; + setCreating(true); + const created = await createCatalogIngredient({ + type: form.type, + name, + payload: {}, + tags: [], + }, { silent: true }).catch((err) => { + toast.error(err?.message || 'Failed to create ingredient'); + return null; + }); + setCreating(false); + if (!created) return; + toast.success(`Created ${form.type} "${name}"`); + setForm({ type: form.type, name: '' }); + setShowForm(false); + // Update list locally (CLAUDE.md: prefer state update over refetch) but + // still refresh stats so the type-chip counts move. + setItems((prev) => [created, ...prev]); + loadStats(); + }; + + const confirmDelete = async (it) => { + setArmedId(null); + setItems((prev) => prev.filter((x) => x.id !== it.id)); + await deleteCatalogIngredient(it.id, { silent: true }).catch((err) => { + toast.error(err?.message || 'Delete failed'); + setItems((prev) => (prev.some((x) => x.id === it.id) ? prev : [it, ...prev])); + }); + loadStats(); + }; + + return ( +
+
+
+
+
+ +
+
+ +
+ + {TYPES.map((t) => ( + + ))} +
+ +
+
+ + {showForm && ( +
+
+
+ + +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="e.g. Echo Saint" + maxLength={200} + autoFocus + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm" + /> +
+
+ + +
+
+
+ )} + + {loading ? ( +
Loading catalog…
+ ) : items.length === 0 ? ( +
+

+ {q || selectedType + ? 'No ingredients match the current filter.' + : 'Your catalog is empty. Paste a scrap on the Ingest page or create one manually.'} +

+
+ ) : ( +
    + {items.map((it) => { + const armed = armedId === it.id; + const name = it.name || '(untitled)'; + return ( +
  • +
    + + {name} + + {armed ? ( + + Delete? + + + + ) : ( + + )} +
    +
    + + {(it.tags || []).slice(0, 4).map((tag) => ( + + {tag} + + ))} +
    + {payloadSnippet(it.payload) ? ( +

    {payloadSnippet(it.payload)}

    + ) : null} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/client/src/pages/CatalogIngest.jsx b/client/src/pages/CatalogIngest.jsx new file mode 100644 index 0000000000..1d931a86d2 --- /dev/null +++ b/client/src/pages/CatalogIngest.jsx @@ -0,0 +1,353 @@ +/** + * CatalogIngest — paste-extract-review-commit flow for the catalog. Three + * phases gated by local state: paste → extracting (live stage checklist via + * `catalog:extract:progress`) → review (per-kind checkboxes with editable + * name + description). Full-width page; owns its own scroll. + */ + +import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Sparkles, Loader2, CheckCircle2, AlertCircle, ArrowLeft, RotateCcw, Circle } from 'lucide-react'; +import toast from '../components/ui/Toast'; +import socket from '../services/socket'; +import { + createCatalogScrap, + extractFromCatalogScrap, + commitCatalogScrapDraft, +} from '../services/apiCatalog'; + +const KIND_SECTIONS = [ + { key: 'characters', label: 'Characters', type: 'character' }, + { key: 'places', label: 'Places', type: 'place' }, + { key: 'objects', label: 'Objects', type: 'object' }, +]; + +// Initial stage list, used until the server's `start` frame supplies the real +// one. Matches the three sections rendered in the review phase so the panel +// never looks empty between click and first frame. +const INITIAL_STAGES = KIND_SECTIONS.map((s) => ({ + id: s.key, label: s.label, status: 'pending', count: 0, +})); + +function StageIcon({ status }) { + if (status === 'completed' || status === 'done') { + return