mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 12:02:49 +05:30
Compare commits
26 Commits
feat/confi
...
c8091ed47c
| Author | SHA1 | Date | |
|---|---|---|---|
| c8091ed47c | |||
| 27c8bb1cb8 | |||
| bfff93e793 | |||
| 9bdc3ca04b | |||
| ea817d8044 | |||
| 382507e71a | |||
| 7df685365e | |||
| b5fb48ed99 | |||
| ae06982620 | |||
| d8d9015015 | |||
|
|
f88cc4efb4 | ||
| 185e68b541 | |||
| ebd0b76c23 | |||
| 2c842b1520 | |||
|
|
b36517e5f3 | ||
| 3c9c55157c | |||
| cf6c015726 | |||
| 8c0f1b45fd | |||
| 6d9155ef28 | |||
| b854dfe45c | |||
| 61c6fe8513 | |||
| 30e26f78f9 | |||
| 1ebd5f77dc | |||
|
|
fd377e8794 | ||
| 817bbde265 | |||
| 4339a8b4b5 |
44
.github/workflows/deploy-docs.yml
vendored
Normal file
44
.github/workflows/deploy-docs.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'web/docs/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Docs to Cloudflare Workers
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Docs
|
||||
run: pnpm --filter docs build
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
workingDirectory: 'web/docs'
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
mail@adityagupta.dev.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
99
CONTRIBUTING.md
Normal file
99
CONTRIBUTING.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Contributing to Omnia
|
||||
|
||||
Thank you for your interest in contributing to Omnia! We welcome contributions from developers, technical writers, and anyone interested in agentic narrative simulation.
|
||||
|
||||
Please take a moment to review this document before submitting contributions.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Documentation](#documentation)
|
||||
2. [Getting Started](#getting-started)
|
||||
3. [Development Workflow](#development-workflow)
|
||||
4. [Coding Standards](#coding-standards)
|
||||
5. [Pull Request Guidelines](#pull-request-guidelines)
|
||||
|
||||
## Documentation
|
||||
|
||||
The primary source of truth for the Omnia project is the official documentation:
|
||||
👉 **[Omnia Documentation](https://omnia.adityagupta.dev/docs)**
|
||||
|
||||
Please refer to the documentation to understand the project architecture, memory model, spatial systems, intents framework, and custom LLM configurations.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Omnia is organized as a monorepo managed with **pnpm** workspaces.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** (v22.13 or newer recommended)
|
||||
- **pnpm** (v11 or newer recommended)
|
||||
|
||||
### Local Setup
|
||||
|
||||
1. Fork the repository and clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/omnia-consolidated.git
|
||||
cd omnia-consolidated
|
||||
```
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
3. Run the Web GUI interface locally:
|
||||
```bash
|
||||
pnpm dev:gui
|
||||
```
|
||||
4. Run the Starlight documentation site locally:
|
||||
```bash
|
||||
pnpm dev:docs
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Branching
|
||||
|
||||
Create a descriptive branch for your changes:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b fix/issue-description
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
Make sure all unit tests pass before submitting changes:
|
||||
|
||||
```bash
|
||||
# Run tests once
|
||||
pnpm test
|
||||
|
||||
# Run tests in watch mode
|
||||
pnpm test:watch
|
||||
```
|
||||
|
||||
### Linting and Formatting
|
||||
|
||||
We enforce consistent code quality and formatting rules across the repository.
|
||||
|
||||
```bash
|
||||
# Check code style and formatting
|
||||
pnpm lint
|
||||
pnpm format:check
|
||||
|
||||
# Auto-fix code style issues
|
||||
pnpm lint:fix
|
||||
pnpm format
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
|
||||
- **TypeScript**: Omnia is written entirely in TypeScript. Ensure all new code is strongly typed.
|
||||
- **Docstrings**: Document public-facing APIs, methods, and configurations.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
1. **Keep PRs Focused**: Keep your changes as small and focused as possible.
|
||||
2. **Include Tests**: If you are introducing a new feature or fixing a bug, write corresponding tests in `tests/`.
|
||||
3. **Update Documentation**: If your changes alter public behavior or introduce new APIs, update the docs under `web/docs/src/content/docs/`.
|
||||
4. **Follow Commit Conventions**: Write clear, descriptive commit messages.
|
||||
139
README.md
139
README.md
@@ -1,49 +1,97 @@
|
||||

|
||||
<p align="center">
|
||||
<img src="web/docs/src/assets/img/logo.png" alt="Omnia Logo" />
|
||||
</p>
|
||||
|
||||
An LLM-assisted narrative simulation engine where the <b>world state lives outside the model</b>, characters act through <b>intents that get validated</b> and applied by engine code, and each character's knowledge, memory, and emotional state are subjective and partial by construction.
|
||||
<h1 align="center">Omnia</h1>
|
||||
|
||||
Omnia is an engine for building narrative RPG-style worlds where characters are played by a language model. It is built to survive long play sessions instead of falling apart after twenty minutes.
|
||||
<p align="center">
|
||||
<b>An architectural framework for multi agent-narrative simulations and fictional worlds!</b>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://omnia.adityagupta.dev/docs"><img src="https://img.shields.io/badge/Omnia_Docs-Read_The_Docs-red?style=for-the-badge" alt="Docs" /></a>
|
||||
<img src="https://img.shields.io/github/license/sortedcord/omnia-consolidated?style=for-the-badge" alt="License" />
|
||||
<img src="https://img.shields.io/github/repo-size/sortedcord/omnia-consolidated?style=for-the-badge" alt="Repo Size" />
|
||||
<img src="https://img.shields.io/github/languages/top/sortedcord/omnia-consolidated?style=for-the-badge" alt="Top Language" />
|
||||
</p>
|
||||
|
||||
## The Problem with the Naive Approach
|
||||
The <b>world state lives outside the model</b>, characters act through <b>intents that get validated</b> and applied by engine code. Each character's knowledge, memory, and emotional state are subjective and partial by construction.
|
||||
|
||||
Single-agent, single-context systems (AI Dungeon and its descendants) prompt one model to _be_ the world and everyone in it. That breaks in predictable ways over long sessions:
|
||||
<p align="center">
|
||||
<img src="./web/docs/src/assets/img/puppet.webp" />
|
||||
</p>
|
||||
|
||||
- **State Leaks:** Characters know things they had no way of learning, because a model with full context cannot help but use it. The assassin's target greets him by name.
|
||||
- **Secrets Refuse to Stay Secret:** "Don't reveal this" is a suggestion a model can argue past, not a mechanism that says no. One clever player question and the conspiracy folds.
|
||||
Single-agent or single-context systems (AI Dungeon and its descendants) prompt one model to _be_ the world and everyone in it. That breaks in predictable ways over long sessions:
|
||||
|
||||
- **State Leaks:** Characters know things they had no way of learning, because a model with full context cannot help but use it.
|
||||
- **Consequences Evaporate:** Betray someone, apologize, and they forgive you a turn later because nothing is tracking the betrayal as a persistent fact.
|
||||
- **Emotional Drift:** Emotional state is either frozen into a meaningless number (`trust: 40`) or handed to the model to grade itself, producing drifting, arbitrary values.
|
||||
- **Stat Drift:** Statistical attributes are either frozen into a meaningless number (`trust: 40`) or handed to the model to grade itself, producing drifting, arbitrary values.
|
||||
- **World Rot:** The world state slowly contradicts itself because the model has no structured place to keep it. The locked door is open, then locked, then never existed.
|
||||
- **Everyone Is One Person:** Every character shares one context, so every character shares one mind. They can't genuinely surprise each other, lie to each other, or know different things — they're sock puppets on the same hand.
|
||||
- **Everyone Is One Person:** Every character shares one context, so every character shares one mind. They can't genuinely surprise each other, lie to each other, or know different things. They're sock puppets on the hands of one puppetmaster.
|
||||
|
||||
The root cause is the same in every case: the model is being asked to be the database, the physics engine, the referee, and the whole cast simultaneously — inside a context window that forgets, blends, and leaks.
|
||||
The model should not be the database, the physics engine and the whole cast simultaneously inside a sliding context window.
|
||||
|
||||
## The Omnia Solution
|
||||
|
||||
Omnia answers every one of these failures with the same move: pull the thing that has to stay consistent out of the model and into structured, queryable, code-controlled state.
|
||||
Omnia answers every one of these failures with the same move: **pull the thing that has to stay consistent out of the model** and into structured, queryable, code-controlled state.
|
||||
|
||||
- **World State:** Lives in a SQLite database, not in a context window. It cannot drift, because nothing regenerates it — it only changes through validated deltas.
|
||||
- **World State:** Lives in a DB, not a context window. It cannot drift, because nothing regenerates it. The world state only changes through validated deltas.
|
||||
- **Actions:** Actions are proposals (Intents) that engine code validates and applies; they are never direct edits the model makes to the world. The model proposes; deterministic code disposes.
|
||||
- **Epistemic Privacy:** Knowledge, memory, and emotion are modeled per character and kept partial on purpose. A character literally cannot reach for what it has not earned the right to know — the secret is not in its prompt, so there is nothing to jailbreak out of it.
|
||||
- **Epistemic Privacy:** Knowledge, memory, and emotion are modeled per character and kept partial on purpose. A character literally cannot reach for what it has not earned the right to know. The secret is not in its prompt, so there is **nothing to jailbreak out of it**.
|
||||
|
||||
## What This Buys You
|
||||
## What this buys you
|
||||
|
||||
The payoff is scenario complexity that uni-agent systems structurally cannot represent, no matter how good the model gets:
|
||||
<p align="center">
|
||||
<img src="./web/docs/src/assets/img/features.webp" />
|
||||
</p>
|
||||
|
||||
The payoff is scenario complexity that **uni-agent systems structurally cannot represent, no matter how good the model gets**.
|
||||
|
||||
- **Real secrets, real dramatic irony.** One NPC knows the sword is cursed; the other does not. This holds for hundreds of turns not because the model is disciplined, but because the second NPC's prompts are constructed from an attribute set that simply does not contain the fact. Leaking it would require the engine to have handed it over.
|
||||
- **Genuine deception between characters.** Because each character acts from its own bounded view, characters can lie to each other — and be believed — with the truth intact in the world state. A con game, a mole in the party, an unreliable ally: these are queries over who-knows-what, not prompt acrobatics.
|
||||
- **Betrayal that stays betrayed.** Events persist as per-observer memory entries with outcomes. An apology adds a memory; it does not delete one.
|
||||
- **Divergent accounts of the same event.** Two witnesses to the same scene hold two different buffer entries, filtered through their own aliases and vantage points. Ask them separately what happened and you get testimony, not a transcript.
|
||||
- **Identity as information.** Characters refer to each other through subjective alias maps ("the hooded figure" vs. "Bob"). Recognizing someone, being recognized, or staying anonymous are all mechanical states — a masked stranger is a masked stranger until the engine says otherwise.
|
||||
- **A physics referee that can say no.** "I pick the lock with a hairpin" is validated against world state by the Architect before anything changes. Failure is a recorded outcome the character remembers, not a narrative the model politely retconned.
|
||||
- **Time that behaves.** A world clock advances by validated, per-action deltas, and memory is recalled with psychologically natural phrasing ("earlier today, in the afternoon" — not a timestamp). Long timelines stay coherent because time is data, not vibes.
|
||||
- **Genuine deception between characters.** Because each entity acts from its own bounded view, they can lie to each other and be believed; with the truth intact in the world state. A con game, a mole in the party, an unreliable ally: these are queries over "who knows what" and not prompt engineering.
|
||||
- Events persist as per observer memory entries with outcomes. An apology adds a memory; it does not delete one.
|
||||
- **Divergent accounts of the same event.** Two witnesses to the same scene hold two different buffer entries, filtered through their own aliases and vantage points. Ask them separately what happened and you get varied testimony.
|
||||
- **A physics referee that can say no.** `I pick the lock with a hairpin` is validated against world state by the Architect before anything changes. Failure is a recorded outcome the entity remembers.
|
||||
- **Time that behaves.** A world clock advances by validated, per-action deltas, and memory is recalled with psychologically natural phrasing ("earlier today, in the afternoon" — not a timestamp). `TimeOfDay` is deterministic and not based on vibes.
|
||||
- **No main character syndrome.** The simulation runs fully autonomously or you act on behalf of any entity. You, the player, are just an entity in the data model, not structurally elevated above the rest of the world. The world can exist without the you.
|
||||
- **Granular model control.** Omnia is not locked to a single LLM. You can pick a different model for **every individual step that calls the LLM**. Narration prose that demands richer reasoning gets a frontier model; quick intent decoding or other generators get smaller ones or a model running entirely on your local machine.
|
||||
|
||||
The general principle: **anything that must remain true is state; the model only ever supplies behavior.** That division of labor is what lets the cast, the secrets, and the timeline scale without the fiction collapsing.
|
||||
The general principle: **anything that must remain true is state; the model only ever supplies behavior.**
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v20+ recommended)
|
||||
- [pnpm](https://pnpm.io/) (v9+ recommended)
|
||||
- An API key for Google Gemini (`GOOGLE_API_KEY` environment variable), or configured settings via the GUI.
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/sortedcord/omnia-consolidated.git
|
||||
cd omnia-consolidated
|
||||
```
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Running the Web GUI
|
||||
|
||||
To launch the Next.js development server for the GUI dashboard:
|
||||
|
||||
```bash
|
||||
pnpm dev:gui
|
||||
```
|
||||
|
||||
Access the application locally at `http://localhost:3000`.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### The Actor Agent
|
||||
|
||||
Each character takes turns through an **Actor Agent** that receives a strictly epistemically-bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its subjective memory buffer, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose — what the character does, says, or _thinks_.
|
||||
Each entity takes turns through an **Actor Agent** that receives a strictly epistemically bounded prompt: its own attributes (public, plus private ones explicitly granted to itself), its subjective memory buffer, the entities co-present at its location, and the current moment. Nothing else. The actor responds with free narrative prose.
|
||||
|
||||
Prose is decoded into typed intents:
|
||||
|
||||
@@ -51,29 +99,29 @@ Prose is decoded into typed intents:
|
||||
- **`action`** — a physical act, subject to validation.
|
||||
- **`monologue`** — an inner thought. No one else perceives it, it bypasses validation entirely, and it is written straight into the character's private memory.
|
||||
|
||||
Not every turn needs an outward act; a character may simply think. This is what makes characters feel inhabited rather than reactive — and it produces a durable, queryable record of each character's private reasoning (see [Research Instrument](#a-research-instrument-model-psychology-in-fiction) below).
|
||||
Not every turn needs an outward act; a character may simply think. This is what makes characters feel inhabited rather than reactive and it produces a queryable record of each character's private reasoning (see [Research Instrument](#a-research-instrument-model-psychology-in-fiction) below).
|
||||
|
||||
The prose generator is pluggable (`IActorProseGenerator`): the same turn loop runs an LLM-driven NPC or a human at a CLI prompt, identically bounded by what their character knows.
|
||||
The prose generator is pluggable (`IActorProseGenerator`): the same turn loop runs an LLM driven NPC or a human, identically bounded by what their character knows. (This is what eliminates Main Character Syndrome)
|
||||
|
||||
### Intents & The World Architect
|
||||
|
||||
An action becomes an **Intent** — a cheap, declarative, allowed-to-be-wrong proposal. Intents route to the **World Architect**, which validates them against the objective world state (dialogue is exempt; monologue never even arrives) and generates structured deltas — starting with time advancement — that deterministic code applies after strict schema (Zod) validation. The model proposes a change; it never touches the database.
|
||||
An action becomes an **Intent** which is a simple proposal that is _allowed to be wrong_. Intents route to the **World Architect**, which validates them against the objective world state and generates structured deltas like time advancement, attribute change, etc. This deterministic code applies after strict schema (Zod) validation. The model never touches the DB.
|
||||
|
||||
This is the load-bearing wall. Because every mutation flows through one validated chokepoint, the world cannot rot: there is no second copy of reality inside a context window to fall out of sync.
|
||||
Because every mutation flows through one validated chokepoint, the world cannot rot: there is no second copy of reality inside a context window to fall out of sync.
|
||||
|
||||
### Attribute-Level Privacy
|
||||
### Attribute Level Privacy
|
||||
|
||||
Every entity, item, and location is an attribute bag. Each attribute carries its own visibility (`PUBLIC` or `PRIVATE`) with an explicit access list. "The sword is cursed" is a private attribute checked in code, not a rule the model is politely asked to honor. Privacy lives at the level of the fact, not the entity — a character can be publicly a blacksmith and privately a spy, and even facts about _itself_ are hidden from it unless explicitly granted (amnesia, repression, and unwitting sleeper agents come free with the model).
|
||||
Every entity, item, and location is an _attribute bag_. Each attribute carries its own visibility (`PUBLIC` or `PRIVATE`) with an explicit access list. "The sword is cursed" is a private attribute checked in code, not a rule the model is politely asked to honor. Privacy lives at the level of the fact, not the entity. A character can be publicly a blacksmith and privately a spy, and even facts about _itself_ are hidden from it unless explicitly granted (amnesia, repression, and unwitting sleeper agents come free with the model).
|
||||
|
||||
The dividend: **prompt-injection-proof secrets.** There is no instruction to override because the information was never serialized into the prompt. Epistemic privacy turns "the model shouldn't say this" (hard, unreliable) into "the model doesn't know this" (trivial, absolute).
|
||||
|
||||
### Spatial Perception
|
||||
|
||||
Space is a graph: `world → region → location → point of interest`, connected by portals with sound and vision propagation values. When something happens, it bubbles outward. There are no coordinates, no pathfinding, no collision geometry — a narrative engine doesn't need a tactical simulation, and a discrete graph is sufficient. Today actors perceive co-located entities and their location's visible attributes; portal-propagated perception is on the roadmap.
|
||||
Space is a graph: `world → region → location → point of interest`, connected by portals with sound and vision propagation values. When something happens, it bubbles outward. There are no coordinates, no pathfinding, no collision geometry — a narrative engine doesn't need a tactical simulation, and a discrete graph is sufficient. Today actors perceive co-located entities and their location's visible attributes; portal propagated perception is on the roadmap.
|
||||
|
||||
### Memory Tiers
|
||||
|
||||
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective — actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
|
||||
- **Verbatim Buffer (implemented):** Per-character subjective event log. Every entry is stored from the owner's perspective actors resolved through the owner's alias map, outcomes attached — and recalled with naturalized time phrasing.
|
||||
- **Vector Archive (planned):** Summarized, embedded memory entries for semantic retrieval, keeping verbatim quotes only for high-salience lines.
|
||||
- **Dossier (planned):** Each observer's subjective beliefs about another character.
|
||||
|
||||
@@ -83,16 +131,20 @@ Memory is per-character on purpose: recall is testimony from a vantage point, wh
|
||||
|
||||
Rather than a scalar the model drifts, every significant interaction becomes a ledger entry with an affect vector across OCC-derived dimensions (plus arousal, dominance, and social drive). The model judges a single moment; deterministic code aggregates the ledger over time with decay and attention weighting. A character can be simultaneously furious about one thing and grateful for another, and an apology does not silently erase a betrayal.
|
||||
|
||||
This however is something that I haven't implementing or plan to implement anytime soon. The mathematical models described in NLAVS is still very abstract and subject to a lot of changes. CAA and RepE is still cutting edge research that I'm still reading papers about.
|
||||
|
||||
Omnia might get an affect vector system however, it's going to be more simplistic than what the NLAVS proposal scribbles down.
|
||||
|
||||
## A Research Instrument: Model Psychology in Fiction
|
||||
|
||||
Omnia's architecture doubles as an apparatus for studying how language models behave _as characters_ under controlled epistemic conditions — something uni-agent setups cannot do, because they can neither control what the model knows nor observe what it withholds.
|
||||
Omnia's architecture doubles as an apparatus for studying how language models behave _as characters_ under controlled epistemic conditions.
|
||||
|
||||
- **A window into private reasoning.** Monologue intents are the model's in-character thoughts: unperceived by other agents, exempt from validation, but durably logged. You can directly compare what a character _thinks_ against what it _says and does_ — measuring deception, self-consistency, motivated reasoning, or the gap between private appraisal and public behavior.
|
||||
- **Knowledge as an experimental variable.** Attribute ACLs let you administer information with precision: give one agent a fact, withhold it from another, and observe propagation, inference, and leakage through dialogue alone. Secret-keeping stops being anecdotal and becomes testable — _provably_, since the engine logs exactly what each agent was ever shown.
|
||||
- **Controlled, reproducible conditions.** A scenario is a JSON file; a run is a SQLite database. Identical initial conditions, swappable model providers behind one interface (`ILLMProvider`), and a deterministic mock for baselines. Rerun the white-room experiment a hundred times, vary one attribute, and diff the transcripts.
|
||||
- **Multi-agent social dynamics with ground truth.** Because objective world state exists independently of any agent's beliefs, you can score agents' beliefs and claims against reality — hallucination, confabulation, and social conformity become measurable quantities rather than impressions.
|
||||
- **A window into private reasoning.** Monologue intents are the model's in character thoughts: unperceived by other agents, exempt from validation, but durably logged. You can directly compare what a character _thinks_ against what it _says and does_: measuring deception, self-consistency, motivated reasoning, etc.
|
||||
- **Knowledge as an experimental variable.** Attribute ACLs let you administer information with precision: give one agent a fact, withhold it from another, and observe propagation, inference, and leakage through dialogue alone.
|
||||
- **Controlled, reproducible conditions.** A scenario is a JSON file (like a template); a run is a SQLite database. Identical initial conditions, swappable model providers behind one interface (`ILLMProvider`), and a deterministic mock for baselines. Rerun the scenario a hundred times, vary one attribute, and diff the transcripts.
|
||||
- **Multi agent social dynamics with ground truth.** Because objective world state exists independently of any agent's beliefs, you can score agents' beliefs and claims against reality like hallucination. Even social conformity become measurable quantities rather than impressions or _✨ vibes_.
|
||||
|
||||
The bundled demo scenario is exactly this: [`talking-room`](./content/demo/scenarios/talking-room.json) places two memory-wiped subjects in a featureless white room — each knowing their own name but not the other's — and observes what they do. It runs today, via the CLI, with a human optionally playing either subject.
|
||||
The bundled demo scenario is exactly this: [`talking-room`](./content/demo/scenarios/talking-room.json) places two memory wiped subjects in a featureless white room. Each know their own name but not the other's. Observe what they do. It runs today, ~~via the CLI, with a human optionally playing either subject~~ via a GUI which is in rapid development. You can let it run forever autonomously or roleplay as either character.
|
||||
|
||||
## Project Status: What `v0` Means
|
||||
|
||||
@@ -112,15 +164,15 @@ The finish line for the first milestone is small on purpose.
|
||||
|
||||
- [x] Two hand-authored NPCs live in one location, playable via CLI.
|
||||
- [ ] Each has buffer and vector-archive memory and recalls something said a few turns earlier. _(buffer: done; vector archive: not started)_
|
||||
- [ ] One NPC knows a fact the other does not and, provably by testing, will not leak it.
|
||||
- [x] One NPC knows a fact the other does not and, provably by testing, will not leak it.
|
||||
- [x] The Architect processes at least one non-trivial action per exchange with a visible state change.
|
||||
- [x] The whole thing persists to a SQLite file and reloads identically.
|
||||
|
||||
**Explicitly out of scope for `v0`:** Constraint validators (beyond basic sense-checking), multi-location perception, affect-vector decay math, the Dossier, whims/simulation tiering, the delta ledger, and UI beyond CLI.
|
||||
**Explicitly out of scope for `v0`:** Constraint validators (beyond basic sense-checking), multi-location perception, affect-vector decay math, the Dossier, whims/simulation tiering, the delta ledger.
|
||||
|
||||
### A Note on Tech Debt
|
||||
|
||||
The Architect currently trusts an LLM's judgement about reasonable consequences rather than validating every change against declarative constraints. A general constraint solver is worth building eventually, but building it before anything is playable is foundational perfectionism that produces beautiful architecture and no game. `v0` keeps the single-call Architect on purpose.
|
||||
The Architect currently trusts an LLM's judgement about reasonable consequences rather than validating every change against declarative constraints. A general constraint solver is worth building eventually, but building it before anything is playable is foundational perfectionism that produces beautiful architecture and no framework. `v0` keeps the single-call Architect on purpose.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -138,17 +190,16 @@ omnia/
|
||||
llm/ ILLMProvider interface plus Gemini and deterministic mock implementations
|
||||
scenario/ scenario JSON schema and loader (JSON → SQLite)
|
||||
apps/
|
||||
cli/ the playable loop (human or LLM actors, --scenario / --play flags)
|
||||
gui/ Next.js Web GUI dashboard and simulation runner
|
||||
content/
|
||||
demo/ bundled scenarios (talking-room)
|
||||
tests/
|
||||
integration/ cross-package tests against a mocked LLM
|
||||
evals/ deliberate real-API evaluation runs
|
||||
docs/ Astro documentation site (→ web/docs/)
|
||||
web/
|
||||
docs/ Astro documentation site
|
||||
```
|
||||
|
||||
_The engine core deliberately knows nothing about domain content (stats, traits, genres). Scenarios are plain JSON the loader ingests; what an attribute means is the scenario's business, not the engine's._
|
||||
|
||||
## Roadmap (Build Order after `v0`)
|
||||
|
||||
1. Vector-archive memory and retrieval (closing out the `v0` memory milestone).
|
||||
|
||||
2
apps/gui/next-env.d.ts
vendored
2
apps/gui/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/architect": "workspace:*",
|
||||
"@omnia/actor": "workspace:*",
|
||||
"@omnia/architect": "workspace:*",
|
||||
"@omnia/core": "workspace:*",
|
||||
"@omnia/intent": "workspace:*",
|
||||
"@omnia/llm": "workspace:*",
|
||||
"@omnia/memory": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"@omnia/scenario": "workspace:*",
|
||||
"@omnia/spatial": "workspace:*",
|
||||
"dotenv": "^17.4.2",
|
||||
"next": "^16.2.10",
|
||||
"react": "^19.2.0",
|
||||
@@ -27,6 +27,9 @@
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
8
apps/gui/postcss.config.mjs
Normal file
8
apps/gui/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
getProviderMappings,
|
||||
setProviderMapping,
|
||||
updateProviderInstance,
|
||||
getAvailableProviders,
|
||||
} from "@/app/play/actions";
|
||||
import type { LLMProviderInstance } from "@omnia/llm";
|
||||
import type { LLMProviderInstance, LLMProviderMeta } from "@omnia/llm";
|
||||
|
||||
interface ConfigStatus {
|
||||
apiKeySet: boolean;
|
||||
@@ -24,10 +25,15 @@ export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<ConfigStatus | null>(null);
|
||||
const [instances, setInstances] = useState<LLMProviderInstance[]>([]);
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({});
|
||||
const [availableProviders, setAvailableProviders] = useState<
|
||||
LLMProviderMeta[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<string | "new">("new");
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editProvider, setEditProvider] = useState("google-genai");
|
||||
const [editKey, setEditKey] = useState("");
|
||||
@@ -35,23 +41,44 @@ export default function ConfigPage() {
|
||||
const [editIsActive, setEditIsActive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedInstanceId === "new") {
|
||||
if (selectedInstanceId === null) {
|
||||
setEditName("");
|
||||
setEditProvider("google-genai");
|
||||
setEditKey("");
|
||||
setEditModel("gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
} else if (selectedInstanceId === "new") {
|
||||
setEditName("");
|
||||
const defaultProvider = "google-genai";
|
||||
setEditProvider(defaultProvider);
|
||||
setEditKey("");
|
||||
const pMeta = availableProviders.find((p) => p.id === defaultProvider);
|
||||
setEditModel(pMeta?.defaultModel || "gemini-2.5-flash");
|
||||
setEditIsActive(false);
|
||||
} else {
|
||||
const inst = instances.find((i) => i.id === selectedInstanceId);
|
||||
if (inst) {
|
||||
setEditName(inst.name);
|
||||
setEditProvider(inst.providerName);
|
||||
setEditKey("");
|
||||
setEditModel(inst.modelName || "gemini-2.5-flash");
|
||||
const pMeta = availableProviders.find(
|
||||
(p) => p.id === inst.providerName,
|
||||
);
|
||||
setEditModel(
|
||||
inst.modelName || pMeta?.defaultModel || "gemini-2.5-flash",
|
||||
);
|
||||
setEditIsActive(inst.isActive);
|
||||
}
|
||||
}
|
||||
}, [selectedInstanceId, instances]);
|
||||
}, [selectedInstanceId, instances, availableProviders]);
|
||||
|
||||
const handleProviderChange = (providerId: string) => {
|
||||
setEditProvider(providerId);
|
||||
const pMeta = availableProviders.find((p) => p.id === providerId);
|
||||
if (pMeta) {
|
||||
setEditModel(pMeta.defaultModel);
|
||||
}
|
||||
};
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
try {
|
||||
@@ -79,6 +106,8 @@ export default function ConfigPage() {
|
||||
setConfig(result);
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
const provs = await getAvailableProviders();
|
||||
setAvailableProviders(provs);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -107,13 +136,24 @@ export default function ConfigPage() {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const created = await createProviderInstance(editName, editProvider, editKey, editModel || undefined);
|
||||
const created = await createProviderInstance(
|
||||
editName,
|
||||
editProvider,
|
||||
editKey,
|
||||
editModel || undefined,
|
||||
);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(created.id);
|
||||
}
|
||||
setSelectedInstanceId(created.id);
|
||||
} else {
|
||||
await updateProviderInstance(selectedInstanceId, editName, editProvider, editKey || undefined, editModel || undefined);
|
||||
await updateProviderInstance(
|
||||
selectedInstanceId,
|
||||
editName,
|
||||
editProvider,
|
||||
editKey || undefined,
|
||||
editModel || undefined,
|
||||
);
|
||||
if (editIsActive) {
|
||||
await setActiveProviderInstance(selectedInstanceId);
|
||||
}
|
||||
@@ -129,14 +169,15 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedInstanceId === "new") return;
|
||||
if (!confirm("Are you sure you want to delete this provider instance?")) return;
|
||||
if (selectedInstanceId === "new" || selectedInstanceId === null) return;
|
||||
if (!confirm("Are you sure you want to delete this provider instance?"))
|
||||
return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
await deleteProviderInstance(selectedInstanceId);
|
||||
setSelectedInstanceId("new");
|
||||
setSelectedInstanceId(null);
|
||||
await loadInstances();
|
||||
await loadMappings();
|
||||
} catch (err) {
|
||||
@@ -146,7 +187,10 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMapping = async (task: string, providerInstanceId: string) => {
|
||||
const handleUpdateMapping = async (
|
||||
task: string,
|
||||
providerInstanceId: string,
|
||||
) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await setProviderMapping(task, providerInstanceId);
|
||||
@@ -159,44 +203,61 @@ export default function ConfigPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="config-page">
|
||||
<h1>Configuration</h1>
|
||||
<div className="mx-auto max-w-[800px] px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl">Configuration</h1>
|
||||
|
||||
{loading && <p>Loading configuration...</p>}
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{config === null && loading && <p>Loading configuration...</p>}
|
||||
{error && (
|
||||
<div className="mb-4 rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && !loading && (
|
||||
<>
|
||||
<section className="config-section">
|
||||
<h2>LLM Provider Instances</h2>
|
||||
<div className="provider-split-container">
|
||||
|
||||
{config && (
|
||||
<div className={loading ? "opacity-60 pointer-events-none transition-opacity duration-200" : "transition-opacity duration-200"}>
|
||||
<section className="mb-8 pb-6">
|
||||
<h2 className="mb-3 text-lg">LLM Provider Instances</h2>
|
||||
<div className="mt-4 grid min-h-[400px] grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white md:grid-cols-[30%_70%]">
|
||||
{/* 30% area */}
|
||||
<div className="provider-list-pane">
|
||||
<div className="pane-header">
|
||||
<h3>Instances</h3>
|
||||
<div className="flex flex-col border-r border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-4 py-4">
|
||||
<h3 className="m-0 text-[0.95rem] font-semibold text-[#111]">
|
||||
Instances
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedInstanceId("new")}
|
||||
className="btn-add-inst"
|
||||
className="cursor-pointer rounded-md bg-emerald-500 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-emerald-600"
|
||||
type="button"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="pane-list">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{instances.length === 0 ? (
|
||||
<div className="no-instances-msg">No instances configured</div>
|
||||
<div className="px-4 py-8 text-center text-xs text-gray-400">
|
||||
No instances configured
|
||||
</div>
|
||||
) : (
|
||||
instances.map((inst) => (
|
||||
<div
|
||||
key={inst.id}
|
||||
onClick={() => setSelectedInstanceId(inst.id)}
|
||||
className={`instance-list-item ${selectedInstanceId === inst.id ? "active" : ""}`}
|
||||
className={`cursor-pointer border-b border-gray-200 border-l-[3px] px-4 py-4 transition-all hover:bg-gray-100 ${
|
||||
selectedInstanceId === inst.id
|
||||
? "border-l-blue-500 bg-blue-50"
|
||||
: "border-l-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="item-name">{inst.name}</div>
|
||||
<div className="item-meta">
|
||||
<div className="text-sm font-medium text-[#111]">
|
||||
{inst.name}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{inst.providerName}</span>
|
||||
{inst.isActive && <span className="active-pill">Active</span>}
|
||||
{inst.isActive && (
|
||||
<span className="rounded-full bg-green-100 px-1.5 py-[1px] text-[0.65rem] font-semibold text-green-700">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -205,127 +266,208 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
|
||||
{/* 70% area */}
|
||||
<div className="provider-form-pane">
|
||||
<form onSubmit={handleSave} className="provider-config-form">
|
||||
<div className="form-scroll-content">
|
||||
<h3>
|
||||
{selectedInstanceId === "new"
|
||||
? "Create New Provider Instance"
|
||||
: `Configure: ${editName}`}
|
||||
</h3>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="formName">Friendly Name</label>
|
||||
<input
|
||||
id="formName"
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="e.g. Gemini - Production"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="formProvider">Provider Type</label>
|
||||
<select
|
||||
id="formProvider"
|
||||
value={editProvider}
|
||||
onChange={(e) => setEditProvider(e.target.value)}
|
||||
>
|
||||
<option value="google-genai">Google Gemini (Gemini-2.5-flash)</option>
|
||||
<option value="mock">Mock LLM Provider</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="formKey">API Key</label>
|
||||
<input
|
||||
id="formKey"
|
||||
type="password"
|
||||
value={editKey}
|
||||
onChange={(e) => setEditKey(e.target.value)}
|
||||
placeholder={
|
||||
selectedInstanceId === "new"
|
||||
? "AIzaSy..."
|
||||
: "•••••••• (unchanged)"
|
||||
}
|
||||
required={selectedInstanceId === "new"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="formModel">Model Name</label>
|
||||
<input
|
||||
id="formModel"
|
||||
type="text"
|
||||
value={editModel}
|
||||
onChange={(e) => setEditModel(e.target.value)}
|
||||
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<input
|
||||
id="formActive"
|
||||
type="checkbox"
|
||||
checked={editIsActive}
|
||||
onChange={(e) => setEditIsActive(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="formActive">Set as Active Instance</label>
|
||||
</div>
|
||||
<div className="flex flex-col bg-white">
|
||||
{selectedInstanceId === null ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center text-sm text-gray-400">
|
||||
Press + to add or select an existing Instance to edit
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSave}
|
||||
className="flex h-full flex-col justify-between"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-5 p-6">
|
||||
<h3 className="m-0 mb-2 text-lg font-semibold text-[#111]">
|
||||
{selectedInstanceId === "new"
|
||||
? "Create New Provider Instance"
|
||||
: `Configure: ${editName}`}
|
||||
</h3>
|
||||
|
||||
<div className="form-actions-bar">
|
||||
<div className="action-left">
|
||||
{selectedInstanceId !== "new" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="btn-delete-pane"
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="formName"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="action-right">
|
||||
<button type="submit" disabled={loading} className="btn-save-pane">
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
Friendly Name
|
||||
</label>
|
||||
<input
|
||||
id="formName"
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="e.g. Gemini - Production"
|
||||
required
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="formProvider"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
Provider Type
|
||||
</label>
|
||||
<select
|
||||
id="formProvider"
|
||||
value={editProvider}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
|
||||
>
|
||||
{availableProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{editProvider && availableProviders.length > 0 && (
|
||||
<span className="mt-1 block rounded border border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-600">
|
||||
{
|
||||
availableProviders.find(
|
||||
(p) => p.id === editProvider,
|
||||
)?.description
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="formKey"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
API Key
|
||||
</label>
|
||||
<input
|
||||
id="formKey"
|
||||
type="password"
|
||||
value={editKey}
|
||||
onChange={(e) => setEditKey(e.target.value)}
|
||||
placeholder={
|
||||
selectedInstanceId === "new"
|
||||
? "AIzaSy..."
|
||||
: "•••••••• (unchanged)"
|
||||
}
|
||||
required={selectedInstanceId === "new"}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="formModel"
|
||||
className="text-xs font-medium text-gray-700"
|
||||
>
|
||||
Model Name
|
||||
</label>
|
||||
<input
|
||||
id="formModel"
|
||||
type="text"
|
||||
value={editModel}
|
||||
onChange={(e) => setEditModel(e.target.value)}
|
||||
placeholder="e.g. gemini-2.5-flash, gemini-2.5-pro"
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm outline-none transition-[border-color,box-shadow] focus:border-blue-500 focus:ring-3 focus:ring-blue-500/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-row items-center gap-2">
|
||||
<input
|
||||
id="formActive"
|
||||
type="checkbox"
|
||||
checked={editIsActive}
|
||||
onChange={(e) => setEditIsActive(e.target.checked)}
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
htmlFor="formActive"
|
||||
className="cursor-pointer text-xs font-medium text-gray-700"
|
||||
>
|
||||
Set as Active Instance
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-4">
|
||||
<div>
|
||||
{selectedInstanceId !== "new" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
className="cursor-pointer rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-600 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="cursor-pointer rounded-md bg-blue-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h2>Task Provider Routing</h2>
|
||||
<p className="config-hint" style={{ background: "#eff6ff", border: "1px solid #bfdbfe", color: "#1e3a8a", margin: "1rem 0" }}>
|
||||
Configure which LLM Provider Key Instance should handle each specific simulation task. Mappings default to the currently <strong>Active</strong> instance if not specified.
|
||||
<section className="mb-8 pb-6">
|
||||
<h2 className="mb-3 text-lg">Task Provider Routing</h2>
|
||||
<p className="my-4 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
||||
Configure which LLM Provider Key Instance should handle each
|
||||
specific simulation task. Mappings default to the currently{" "}
|
||||
<strong>Active</strong> instance if not specified.
|
||||
</p>
|
||||
<div className="mappings-grid">
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{[
|
||||
{ key: "actor-prose", label: "Actor Prose Generation", desc: "Generates roleplay/narrative prose for Non-Player Characters." },
|
||||
{ key: "llm-validator", label: "LLM Validator", desc: "Arbitrates and validates proposed actions against the world state rules." },
|
||||
{ key: "intent-decoder", label: "Intent Decoder", desc: "Splits raw prose actions into structured intents (Player and NPC)." },
|
||||
{ key: "timedelta", label: "TimeDelta Generator", desc: "Calculates the duration of character actions to advance the game clock." },
|
||||
{
|
||||
key: "actor-prose",
|
||||
label: "Actor Prose Generation",
|
||||
desc: "Generates roleplay/narrative prose for Non-Player Characters.",
|
||||
},
|
||||
{
|
||||
key: "llm-validator",
|
||||
label: "LLM Validator",
|
||||
desc: "Arbitrates and validates proposed actions against the world state rules.",
|
||||
},
|
||||
{
|
||||
key: "intent-decoder",
|
||||
label: "Intent Decoder",
|
||||
desc: "Splits raw prose actions into structured intents (Player and NPC).",
|
||||
},
|
||||
{
|
||||
key: "timedelta",
|
||||
label: "TimeDelta Generator",
|
||||
desc: "Calculates the duration of character actions to advance the game clock.",
|
||||
},
|
||||
].map((task) => (
|
||||
<div key={task.key} className="mapping-card">
|
||||
<div className="mapping-info">
|
||||
<strong>{task.label}</strong>
|
||||
<span className="text-gray" style={{ fontSize: "0.75rem", marginTop: "0.125rem" }}>{task.desc}</span>
|
||||
<div
|
||||
key={task.key}
|
||||
className="flex flex-col justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4"
|
||||
>
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<strong className="text-sm text-[#111]">
|
||||
{task.label}
|
||||
</strong>
|
||||
<span className="mt-0.5 text-gray-500">{task.desc}</span>
|
||||
</div>
|
||||
<select
|
||||
value={mappings[task.key] || ""}
|
||||
onChange={(e) => handleUpdateMapping(task.key, e.target.value)}
|
||||
onChange={(e) =>
|
||||
handleUpdateMapping(task.key, e.target.value)
|
||||
}
|
||||
className="w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs"
|
||||
>
|
||||
<option value="">-- Use Active Key (Default) --</option>
|
||||
<option value="">Use Default Provider</option>
|
||||
{instances.map((inst) => (
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({inst.providerName}){inst.isActive ? " [Active]" : ""}
|
||||
{inst.name} ({inst.providerName})
|
||||
{inst.isActive ? " [Active]" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -334,50 +476,36 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h2>Environment Variables Default</h2>
|
||||
<div className="config-row">
|
||||
<span className="config-label">Default Model</span>
|
||||
<span className="config-value">
|
||||
<code>{config.model}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="config-row">
|
||||
<span className="config-label">Default API Key (.env)</span>
|
||||
<span
|
||||
className={
|
||||
config.apiKeySet
|
||||
? "config-value status-ok"
|
||||
: "config-value status-error"
|
||||
}
|
||||
>
|
||||
{config.apiKeySet
|
||||
? `✓ Set (${config.apiKeyPreview})`
|
||||
: "✗ NOT SET"}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h2>Available Scenarios</h2>
|
||||
<section className="mb-8 pb-6">
|
||||
<h2 className="mb-3 text-lg">Available Scenarios</h2>
|
||||
{config.availableScenarios.length === 0 ? (
|
||||
<p className="config-hint">
|
||||
No scenarios found in <code>content/demo/scenarios/</code>.
|
||||
<p className="mt-3 rounded border border-amber-200 bg-amber-100 px-3 py-2 text-xs text-amber-800">
|
||||
No scenarios found in{" "}
|
||||
<code className="font-mono text-xs">
|
||||
content/demo/scenarios/
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<table className="scenario-table">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Path</th>
|
||||
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
|
||||
Name
|
||||
</th>
|
||||
<th className="border-b-2 border-gray-200 p-2 text-left font-medium text-gray-500">
|
||||
Path
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{config.availableScenarios.map((s) => (
|
||||
<tr key={s.path}>
|
||||
<td>{s.name}</td>
|
||||
<td>
|
||||
<code>{s.path}</code>
|
||||
<td className="border-b border-gray-100 p-2">{s.name}</td>
|
||||
<td className="border-b border-gray-100 p-2">
|
||||
<code className="font-mono text-xs text-blue-600">
|
||||
{s.path}
|
||||
</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -385,393 +513,8 @@ export default function ConfigPage() {
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="config-section">
|
||||
<h2>Engine Packages</h2>
|
||||
<p className="config-hint">
|
||||
All <code>@omnia/*</code> workspace packages are consumed via{" "}
|
||||
<code>transpilePackages</code> in <code>next.config.ts</code>.
|
||||
The native <code>better-sqlite3</code> module is externalized via{" "}
|
||||
<code>serverExternalPackages</code>.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.config-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
.config-page h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.config-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.config-section h2 {
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.config-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.config-label {
|
||||
color: #555;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.config-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.status-ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
.status-error {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
.config-hint {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
color: #92400e;
|
||||
}
|
||||
.config-hint code {
|
||||
background: rgba(0,0,0,0.06);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.error-banner {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.scenario-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.scenario-table th {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
.scenario-table td {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.scenario-table code {
|
||||
font-size: 0.8125rem;
|
||||
color: #2563eb;
|
||||
}
|
||||
code {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Pill and List Styles */
|
||||
.status-pill {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-pill.active {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
.status-pill.inactive {
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
}
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-sm:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-sm.delete-btn {
|
||||
background: #ef4444;
|
||||
}
|
||||
.btn-sm.delete-btn:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
/* Split container */
|
||||
.provider-split-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
margin-top: 1rem;
|
||||
min-height: 400px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.provider-split-container {
|
||||
grid-template-columns: 30% 70%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 30% List Pane */
|
||||
.provider-list-pane {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pane-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.pane-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
.btn-add-inst {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-add-inst:hover {
|
||||
background: #059669;
|
||||
}
|
||||
.pane-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.no-instances-msg {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.instance-list-item {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-left 0.15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
.instance-list-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.instance-list-item.active {
|
||||
background: #eff6ff;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: #111;
|
||||
}
|
||||
.item-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.active-pill {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* 70% Form Pane */
|
||||
.provider-form-pane {
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.provider-config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.form-scroll-content {
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.form-scroll-content h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.form-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="password"],
|
||||
.form-group select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
.checkbox-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.checkbox-group label {
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox-group input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Action bar */
|
||||
.form-actions-bar {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-delete-pane {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-delete-pane:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
.btn-save-pane {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.btn-save-pane:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Task Provider Routing Styles */
|
||||
.mappings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.mappings-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
.mapping-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.mapping-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.mapping-info strong {
|
||||
font-size: 0.875rem;
|
||||
color: #111;
|
||||
}
|
||||
.mapping-card select {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
.text-gray {
|
||||
color: #6b7280;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
color: #111;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
@layer base {
|
||||
html {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/gui/src/app/icon.png
Normal file
BIN
apps/gui/src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 919 B |
@@ -10,7 +10,7 @@ export const metadata = {
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<body className="min-h-dvh bg-[#fafafa] text-[#111]">
|
||||
<NavBar />
|
||||
{children}
|
||||
</body>
|
||||
|
||||
@@ -2,62 +2,31 @@ import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="home">
|
||||
<h1>Omnia GUI</h1>
|
||||
<p className="subtitle">
|
||||
<main className="mx-auto max-w-[800px] px-4 py-12">
|
||||
<h1 className="mb-2 text-3xl">Omnia GUI</h1>
|
||||
<p className="mb-8 text-gray-500">
|
||||
Configuration and gameplay interface for the Omnia simulation engine.
|
||||
</p>
|
||||
<div className="home-links">
|
||||
<Link href="/play" className="home-card">
|
||||
<h2>Play</h2>
|
||||
<p>Start a simulation and interact with NPCs</p>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/play"
|
||||
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
|
||||
>
|
||||
<h2 className="mb-1 text-xl">Play</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Start a simulation and interact with NPCs
|
||||
</p>
|
||||
</Link>
|
||||
<Link href="/config" className="home-card">
|
||||
<h2>Config</h2>
|
||||
<p>Check environment, API keys, and available scenarios</p>
|
||||
<Link
|
||||
href="/config"
|
||||
className="block flex-1 rounded-lg border border-gray-200 p-6 text-inherit no-underline transition-[border-color,box-shadow] duration-150 hover:border-blue-600 hover:shadow-[0_2px_8px_rgba(37,99,235,0.1)]"
|
||||
>
|
||||
<h2 className="mb-1 text-xl">Config</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Check environment, API keys, and available scenarios
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
<style>{`
|
||||
.home {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
.home h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.subtitle {
|
||||
color: #555;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.home-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.home-card {
|
||||
flex: 1;
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.home-card:hover {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
.home-card h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.home-card p {
|
||||
font-size: 0.875rem;
|
||||
color: #555;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import { simulationManager } from "@/lib/simulation";
|
||||
import type { SimSnapshot } from "@/lib/simulation";
|
||||
import { ProviderManager, LLMProviderInstance } from "@omnia/llm";
|
||||
import { ProviderManager, LLMProviderInstance, AVAILABLE_PROVIDERS, LLMProviderMeta } from "@omnia/llm";
|
||||
|
||||
function resolveScenarioPath(relative: string): string {
|
||||
const cwd = process.cwd();
|
||||
@@ -274,3 +274,7 @@ export async function setProviderMapping(
|
||||
): Promise<void> {
|
||||
ProviderManager.setMapping(task, providerInstanceId);
|
||||
}
|
||||
|
||||
export async function getAvailableProviders(): Promise<LLMProviderMeta[]> {
|
||||
return AVAILABLE_PROVIDERS;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ import type { LLMProviderInstance } from "@omnia/llm";
|
||||
|
||||
function IntentTag({
|
||||
intent,
|
||||
isSelf,
|
||||
}: {
|
||||
intent: SimSnapshot["log"][number]["intents"][number];
|
||||
isSelf?: boolean;
|
||||
}) {
|
||||
const labels: Record<string, string> = {
|
||||
monologue: "thought",
|
||||
@@ -33,9 +35,19 @@ function IntentTag({
|
||||
outcome = intent.isValid ? " ✅" : ` ❌ (${intent.reason})`;
|
||||
}
|
||||
|
||||
const textToDisplay = (isSelf && intent.selfDescription)
|
||||
? intent.selfDescription
|
||||
: intent.description;
|
||||
|
||||
const modifiersStr = intent.modifiers && intent.modifiers.length > 0 ? (
|
||||
<span className="intent-modifiers" style={{ fontStyle: "italic", opacity: 0.8, color: "#4b5563", marginLeft: "0.25rem" }}>
|
||||
({intent.modifiers.join(", ")})
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="intent-tag">
|
||||
[{label}] “{intent.description}”{outcome}
|
||||
[{label}] “{textToDisplay}”{modifiersStr}{outcome}
|
||||
{intent.minutesToAdvance ? ` [+${intent.minutesToAdvance}min]` : ""}
|
||||
</span>
|
||||
);
|
||||
@@ -50,6 +62,77 @@ function PromptModal({
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<"actor" | "decoder">("actor");
|
||||
|
||||
const parseActorPrompt = (systemPrompt: string, userContext: string, inputTokens: number) => {
|
||||
const memoryHeader = "=== YOUR RECENT MEMORY ===";
|
||||
const idx = userContext.indexOf(memoryHeader);
|
||||
|
||||
let worldStr = userContext;
|
||||
let memStr = "";
|
||||
|
||||
if (idx !== -1) {
|
||||
worldStr = userContext.substring(0, idx).trim();
|
||||
memStr = userContext.substring(idx).trim();
|
||||
}
|
||||
|
||||
const sysLen = systemPrompt.length;
|
||||
const worldLen = worldStr.length;
|
||||
const memLen = memStr.length;
|
||||
const totalLen = sysLen + worldLen + memLen;
|
||||
|
||||
if (totalLen === 0) return null;
|
||||
|
||||
const sysPct = (sysLen / totalLen) * 100;
|
||||
const worldPct = (worldLen / totalLen) * 100;
|
||||
const memPct = (memLen / totalLen) * 100;
|
||||
|
||||
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
|
||||
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
|
||||
const memTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
|
||||
|
||||
return [
|
||||
{ label: "System Prompt", pct: sysPct, tokens: sysTokens, type: "system", content: systemPrompt },
|
||||
{ label: "World Info", pct: worldPct, tokens: worldTokens, type: "world", content: worldStr },
|
||||
{ label: "Recent Memories", pct: memPct, tokens: memTokens, type: "memories", content: memStr || "(No memories yet.)" },
|
||||
];
|
||||
};
|
||||
|
||||
const parseDecoderPrompt = (systemPrompt: string, userContext: string, inputTokens: number) => {
|
||||
const proseHeader = "=== NARRATIVE PROSE ===";
|
||||
const idx = userContext.indexOf(proseHeader);
|
||||
|
||||
let worldStr = userContext;
|
||||
let proseStr = "";
|
||||
|
||||
if (idx !== -1) {
|
||||
worldStr = userContext.substring(0, idx).trim();
|
||||
proseStr = userContext.substring(idx).trim();
|
||||
}
|
||||
|
||||
const sysLen = systemPrompt.length;
|
||||
const worldLen = worldStr.length;
|
||||
const proseLen = proseStr.length;
|
||||
const totalLen = sysLen + worldLen + proseLen;
|
||||
|
||||
if (totalLen === 0) return null;
|
||||
|
||||
const sysPct = (sysLen / totalLen) * 100;
|
||||
const worldPct = (worldLen / totalLen) * 100;
|
||||
const prosePct = (proseLen / totalLen) * 100;
|
||||
|
||||
const sysTokens = Math.round((sysLen / totalLen) * inputTokens);
|
||||
const worldTokens = Math.round((worldLen / totalLen) * inputTokens);
|
||||
const proseTokens = Math.max(0, inputTokens - sysTokens - worldTokens);
|
||||
|
||||
return [
|
||||
{ label: "System Prompt", pct: sysPct, tokens: sysTokens, type: "system", content: systemPrompt },
|
||||
{ label: "Decoder Context", pct: worldPct, tokens: worldTokens, type: "world", content: worldStr },
|
||||
{ label: "Narrative Prose", pct: prosePct, tokens: proseTokens, type: "memories", content: proseStr },
|
||||
];
|
||||
};
|
||||
|
||||
const actorBreakdown = (entry.rawPrompt && entry.usage) ? parseActorPrompt(entry.rawPrompt.systemPrompt, entry.rawPrompt.userContext, entry.usage.inputTokens) : null;
|
||||
const decoderBreakdown = (entry.decoderPrompt && entry.decoderUsage) ? parseDecoderPrompt(entry.decoderPrompt.systemPrompt, entry.decoderPrompt.userContext, entry.decoderUsage.inputTokens) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!entry.rawPrompt && entry.decoderPrompt) {
|
||||
setActiveTab("decoder");
|
||||
@@ -85,50 +168,124 @@ function PromptModal({
|
||||
{activeTab === "actor" && entry.rawPrompt && (
|
||||
<div className="tab-pane">
|
||||
{entry.usage ? (
|
||||
<div className="usage-stats">
|
||||
<strong>Token Usage:</strong>
|
||||
<span>Input: <code>{entry.usage.inputTokens}</code></span> ·{" "}
|
||||
<span>Output: <code>{entry.usage.outputTokens}</code></span> ·{" "}
|
||||
<span>Total: <code>{entry.usage.totalTokens}</code></span>
|
||||
<div className="provider-info">
|
||||
<strong>LLM Instance:</strong> <span>{entry.usage.providerInstanceName || "Default"}</span>
|
||||
{entry.usage.modelName && (
|
||||
<span> ({entry.usage.modelName})</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="usage-stats italic text-gray">
|
||||
<div className="provider-info italic text-gray">
|
||||
No LLM token usage (Player turn used fixed prose).
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prompt-field">
|
||||
<h4>System Prompt</h4>
|
||||
<pre>{entry.rawPrompt.systemPrompt}</pre>
|
||||
</div>
|
||||
{actorBreakdown && (
|
||||
<div className="prompt-breakdown-container">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.25rem" }}>
|
||||
<span style={{ fontWeight: 600 }}>Input Prompt Breakdown</span>
|
||||
<span>Total Input Tokens: <strong>{entry.usage?.inputTokens}</strong></span>
|
||||
</div>
|
||||
<div className="prompt-breakdown-bar">
|
||||
{actorBreakdown.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`bar-section ${item.type}`}
|
||||
style={{ width: `${item.pct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="breakdown-accordion">
|
||||
{actorBreakdown.map((item, idx) => (
|
||||
<details key={idx} className="breakdown-accordion-item" open={idx === 0}>
|
||||
<summary className="accordion-header">
|
||||
<span className={`legend-color ${item.type}`} />
|
||||
<span className="header-text">
|
||||
{item.label}: <strong>{item.tokens}</strong> tokens ({item.pct.toFixed(0)}%)
|
||||
</span>
|
||||
<span className="accordion-chevron">▼</span>
|
||||
</summary>
|
||||
<div className="accordion-content">
|
||||
<pre>{item.content}</pre>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prompt-field">
|
||||
<h4>User Context</h4>
|
||||
<pre>{entry.rawPrompt.userContext}</pre>
|
||||
</div>
|
||||
{entry.usage && (
|
||||
<div className="prompt-output-section" style={{ marginTop: "0.5rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.5rem" }}>
|
||||
<span style={{ fontWeight: 600 }}>LLM Output</span>
|
||||
<span>Total Output Tokens: <strong>{entry.usage.outputTokens}</strong></span>
|
||||
</div>
|
||||
<div className="accordion-content" style={{ border: "1px solid #e5e7eb", borderRadius: "6px" }}>
|
||||
<pre>{entry.narrativeProse}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "decoder" && entry.decoderPrompt && (
|
||||
<div className="tab-pane">
|
||||
{entry.decoderUsage && (
|
||||
<div className="usage-stats">
|
||||
<strong>Token Usage:</strong>
|
||||
<span>Input: <code>{entry.decoderUsage.inputTokens}</code></span> ·{" "}
|
||||
<span>Output: <code>{entry.decoderUsage.outputTokens}</code></span> ·{" "}
|
||||
<span>Total: <code>{entry.decoderUsage.totalTokens}</code></span>
|
||||
<div className="provider-info">
|
||||
<strong>LLM Instance:</strong> <span>{entry.decoderUsage.providerInstanceName || "Default"}</span>
|
||||
{entry.decoderUsage.modelName && (
|
||||
<span> ({entry.decoderUsage.modelName})</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prompt-field">
|
||||
<h4>System Prompt</h4>
|
||||
<pre>{entry.decoderPrompt.systemPrompt}</pre>
|
||||
</div>
|
||||
{decoderBreakdown && (
|
||||
<div className="prompt-breakdown-container">
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.25rem" }}>
|
||||
<span style={{ fontWeight: 600 }}>Input Prompt Breakdown</span>
|
||||
<span>Total Input Tokens: <strong>{entry.decoderUsage?.inputTokens}</strong></span>
|
||||
</div>
|
||||
<div className="prompt-breakdown-bar">
|
||||
{decoderBreakdown.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`bar-section ${item.type}`}
|
||||
style={{ width: `${item.pct}%` }}
|
||||
title={`${item.label}: ${item.tokens} tokens (${item.pct.toFixed(1)}%)`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="breakdown-accordion">
|
||||
{decoderBreakdown.map((item, idx) => (
|
||||
<details key={idx} className="breakdown-accordion-item" open={idx === 0}>
|
||||
<summary className="accordion-header">
|
||||
<span className={`legend-color ${item.type}`} />
|
||||
<span className="header-text">
|
||||
{item.label}: <strong>{item.tokens}</strong> tokens ({item.pct.toFixed(0)}%)
|
||||
</span>
|
||||
<span className="accordion-chevron">▼</span>
|
||||
</summary>
|
||||
<div className="accordion-content">
|
||||
<pre>{item.content}</pre>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="prompt-field">
|
||||
<h4>User Context</h4>
|
||||
<pre>{entry.decoderPrompt.userContext}</pre>
|
||||
</div>
|
||||
{entry.decoderUsage && (
|
||||
<div className="prompt-output-section" style={{ marginTop: "0.5rem" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", fontSize: "0.75rem", color: "#6b7280", marginBottom: "0.5rem" }}>
|
||||
<span style={{ fontWeight: 600 }}>LLM Output</span>
|
||||
<span>Total Output Tokens: <strong>{entry.decoderUsage.outputTokens}</strong></span>
|
||||
</div>
|
||||
<div className="accordion-content" style={{ border: "1px solid #e5e7eb", borderRadius: "6px" }}>
|
||||
<pre>{JSON.stringify(entry.intents, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,12 +294,30 @@ function PromptModal({
|
||||
);
|
||||
}
|
||||
|
||||
function formatSimTime(isoString: string) {
|
||||
try {
|
||||
const d = new Date(isoString);
|
||||
if (isNaN(d.getTime())) return isoString;
|
||||
const yyyy = d.getUTCFullYear();
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||
const min = String(d.getUTCMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getUTCSeconds()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss} UTC`;
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
function LogEntryCard({
|
||||
entry,
|
||||
onShowPrompt,
|
||||
isPlayerCard,
|
||||
}: {
|
||||
entry: SimSnapshot["log"][number];
|
||||
onShowPrompt: (entry: SimSnapshot["log"][number]) => void;
|
||||
isPlayerCard: boolean;
|
||||
}) {
|
||||
const showMenu = !!(entry.rawPrompt || entry.decoderPrompt);
|
||||
|
||||
@@ -153,7 +328,7 @@ function LogEntryCard({
|
||||
<strong>{entry.entityName}</strong>
|
||||
<span className="log-meta">
|
||||
Turn {entry.turn} ·{" "}
|
||||
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||
{formatSimTime(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{showMenu && (
|
||||
@@ -169,7 +344,7 @@ function LogEntryCard({
|
||||
<div className="log-prose">{entry.narrativeProse}</div>
|
||||
<div className="log-intents">
|
||||
{entry.intents.map((intent, i) => (
|
||||
<IntentTag key={i} intent={intent} />
|
||||
<IntentTag key={i} intent={intent} isSelf={isPlayerCard} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,6 +360,7 @@ export function PlayView() {
|
||||
const [selectedEntryForModal, setSelectedEntryForModal] = useState<SimSnapshot["log"][number] | null>(null);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const steppingRef = useRef(false);
|
||||
const pauseRequestedRef = useRef(false);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setTimeout(
|
||||
@@ -203,10 +379,14 @@ export function PlayView() {
|
||||
steppingRef.current = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
pauseRequestedRef.current = false;
|
||||
|
||||
try {
|
||||
let current = snapshot;
|
||||
while (true) {
|
||||
if (pauseRequestedRef.current) {
|
||||
break;
|
||||
}
|
||||
const result = await stepSimulation({ simId: id });
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
@@ -276,6 +456,8 @@ export function PlayView() {
|
||||
setSnapshot(res.snapshot);
|
||||
if (res.snapshot.status === "running") {
|
||||
await runSteps(res.snapshot.id);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to resume session.");
|
||||
@@ -307,7 +489,6 @@ export function PlayView() {
|
||||
const [selectedEntity, setSelectedEntity] = useState("");
|
||||
|
||||
const [providerInstances, setProviderInstances] = useState<LLMProviderInstance[]>([]);
|
||||
const [selectedProviderInstance, setSelectedProviderInstance] = useState("");
|
||||
|
||||
// Load scenarios and provider instances on mount
|
||||
useEffect(() => {
|
||||
@@ -324,12 +505,6 @@ export function PlayView() {
|
||||
try {
|
||||
const providersList = await listProviderInstances();
|
||||
setProviderInstances(providersList);
|
||||
const active = providersList.find(p => p.isActive);
|
||||
if (active) {
|
||||
setSelectedProviderInstance(active.id);
|
||||
} else if (providersList.length > 0) {
|
||||
setSelectedProviderInstance(providersList[0].id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -372,7 +547,6 @@ export function PlayView() {
|
||||
const result = await startSimulation({
|
||||
scenario: (form.get("scenario") as string) || undefined,
|
||||
playEntity: (form.get("playEntity") as string) || undefined,
|
||||
providerInstanceId: selectedProviderInstance || undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -385,6 +559,8 @@ export function PlayView() {
|
||||
|
||||
if (result.snapshot.status === "running") {
|
||||
await runSteps(result.snapshot.id);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -491,26 +667,8 @@ export function PlayView() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="llmInstance">LLM Key / Instance</label>
|
||||
<select
|
||||
id="llmInstance"
|
||||
value={selectedProviderInstance}
|
||||
onChange={(e) => setSelectedProviderInstance(e.target.value)}
|
||||
disabled={providerInstances.length === 0}
|
||||
>
|
||||
{providerInstances.length === 0 ? (
|
||||
<option value="">Default (from Env variable)</option>
|
||||
) : (
|
||||
providerInstances.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} ({p.providerName}) {p.isActive ? " [Active]" : ""}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" disabled={loading}>
|
||||
|
||||
<button type="submit" disabled={loading || providerInstances.length === 0}>
|
||||
{loading ? "Starting..." : "Start Simulation"}
|
||||
</button>
|
||||
</form>
|
||||
@@ -534,7 +692,7 @@ export function PlayView() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button onClick={() => handleResume(s.id)} disabled={loading}>
|
||||
<button onClick={() => handleResume(s.id)} disabled={loading || providerInstances.length === 0}>
|
||||
Resume
|
||||
</button>
|
||||
<button
|
||||
@@ -560,15 +718,36 @@ export function PlayView() {
|
||||
<div className="sim-info-header">
|
||||
<h2>{snapshot.scenarioName}</h2>
|
||||
{snapshot.status !== "done" && snapshot.status !== "error" && (
|
||||
<button
|
||||
className="stop-btn"
|
||||
onClick={() => {
|
||||
setSnapshot(null);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{snapshot.status === "running" && (
|
||||
loading ? (
|
||||
<button
|
||||
className="pause-btn"
|
||||
onClick={() => {
|
||||
pauseRequestedRef.current = true;
|
||||
}}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="resume-btn"
|
||||
onClick={() => runSteps(snapshot.id)}
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
className="stop-btn"
|
||||
onClick={() => {
|
||||
setSnapshot(null);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p>{snapshot.scenarioDescription}</p>
|
||||
@@ -579,13 +758,17 @@ export function PlayView() {
|
||||
</div>
|
||||
|
||||
<div className="log-container">
|
||||
{snapshot.log.map((entry, i) => (
|
||||
<LogEntryCard
|
||||
key={i}
|
||||
entry={entry}
|
||||
onShowPrompt={setSelectedEntryForModal}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const playerEntity = snapshot.entities.find((e) => e.isPlayer);
|
||||
return snapshot.log.map((entry, i) => (
|
||||
<LogEntryCard
|
||||
key={i}
|
||||
entry={entry}
|
||||
onShowPrompt={setSelectedEntryForModal}
|
||||
isPlayerCard={entry.entityId === playerEntity?.id}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
{loading && (
|
||||
<div className="log-processing">
|
||||
<span className="spinner" />
|
||||
@@ -721,6 +904,18 @@ export function PlayView() {
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.pause-btn {
|
||||
background: #d97706;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.resume-btn {
|
||||
background: #059669;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
}
|
||||
|
||||
.sim-info {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -959,18 +1154,112 @@ export function PlayView() {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.usage-stats {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #1e3a8a;
|
||||
padding: 0.625rem 0.875rem;
|
||||
.provider-info {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
color: #374151;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.prompt-breakdown-bar {
|
||||
display: flex;
|
||||
height: 24px;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #e5e7eb;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.bar-section {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.bar-section.system {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.bar-section.world {
|
||||
background: #10b981;
|
||||
}
|
||||
.bar-section.memories {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.breakdown-accordion {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
details.breakdown-accordion-item {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
summary.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f9fafb;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
summary.accordion-header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
summary.accordion-header {
|
||||
list-style: none;
|
||||
}
|
||||
.header-text {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.accordion-chevron {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
details[open] .accordion-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.accordion-content {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #fafafa;
|
||||
}
|
||||
.accordion-content pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
color: #1f2937;
|
||||
}
|
||||
.legend-color {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
.legend-color.system {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.legend-color.world {
|
||||
background: #10b981;
|
||||
}
|
||||
.legend-color.memories {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.usage-stats code {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #1d4ed8;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface IntentInfo {
|
||||
type: string;
|
||||
description: string;
|
||||
selfDescription?: string;
|
||||
modifiers: string[];
|
||||
targetIds: string[];
|
||||
isValid?: boolean;
|
||||
reason?: string;
|
||||
@@ -22,6 +24,8 @@ export interface LogEntry {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
};
|
||||
decoderPrompt?: {
|
||||
systemPrompt: string;
|
||||
@@ -31,6 +35,8 @@ export interface LogEntry {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
IActorProseGenerator,
|
||||
buildBufferEntryForIntent,
|
||||
} from "@omnia/actor";
|
||||
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager } from "@omnia/llm";
|
||||
import { GeminiProvider, ILLMProvider, MockLLMProvider, ProviderManager, OpenRouterProvider } from "@omnia/llm";
|
||||
import { ScenarioLoader } from "@omnia/scenario";
|
||||
|
||||
import type {
|
||||
@@ -119,17 +119,10 @@ class SimulationManager {
|
||||
playEntityName?: string,
|
||||
providerInstanceId?: string,
|
||||
): Promise<SimSnapshot> {
|
||||
let activeInstance = providerInstanceId
|
||||
const activeInstance = providerInstanceId
|
||||
? ProviderManager.list().find((p) => p.id === providerInstanceId)
|
||||
: ProviderManager.getActive();
|
||||
|
||||
if (!activeInstance) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
activeInstance = ProviderManager.create("Default (Env)", "google-genai", envKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeInstance) {
|
||||
return {
|
||||
id: "",
|
||||
@@ -231,9 +224,13 @@ class SimulationManager {
|
||||
|
||||
const key = inst ? inst.apiKey : (process.env.GOOGLE_API_KEY || "");
|
||||
const providerName = inst ? inst.providerName : "google-genai";
|
||||
const modelName = inst ? inst.modelName : undefined;
|
||||
const instanceName = inst ? inst.name : undefined;
|
||||
|
||||
if (providerName === "google-genai") {
|
||||
return new GeminiProvider(key);
|
||||
return new GeminiProvider(key, modelName, instanceName);
|
||||
} else if (providerName === "openrouter") {
|
||||
return new OpenRouterProvider(key, modelName, instanceName);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
@@ -388,6 +385,8 @@ class SimulationManager {
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
selfDescription: intent.selfDescription,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
@@ -531,6 +530,8 @@ class SimulationManager {
|
||||
entry.intents.push({
|
||||
type: intent.type,
|
||||
description: intent.description,
|
||||
selfDescription: intent.selfDescription,
|
||||
modifiers: intent.modifiers || [],
|
||||
targetIds: intent.targetIds,
|
||||
isValid: outcome.isValid,
|
||||
reason: outcome.reason,
|
||||
@@ -656,19 +657,15 @@ class SimulationManager {
|
||||
if (!inst) {
|
||||
inst = active;
|
||||
}
|
||||
if (!inst) {
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
inst = ProviderManager.create("Default (Env)", "google-genai", envKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!inst) {
|
||||
throw new Error(`No active LLM Provider Instance found for task "${task}". Please configure a key in Settings first.`);
|
||||
}
|
||||
|
||||
if (inst.providerName === "google-genai") {
|
||||
return new GeminiProvider(inst.apiKey);
|
||||
return new GeminiProvider(inst.apiKey, inst.modelName, inst.name);
|
||||
} else if (inst.providerName === "openrouter") {
|
||||
return new OpenRouterProvider(inst.apiKey, inst.modelName, inst.name);
|
||||
} else {
|
||||
return new MockLLMProvider([]);
|
||||
}
|
||||
|
||||
11
apps/gui/tailwind.config.ts
Normal file
11
apps/gui/tailwind.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -11,12 +11,6 @@
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": []
|
||||
},
|
||||
{
|
||||
"name": "observation_status",
|
||||
"value": "Active monitoring. Audio and visual feeds online.",
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": []
|
||||
},
|
||||
{
|
||||
"name": "ambient_sound",
|
||||
"value": "A low, barely audible electrical hum.",
|
||||
@@ -54,6 +48,11 @@
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": ["7c9b83b3-8cfb-4e89-8d77-626a5757d591"]
|
||||
},
|
||||
{
|
||||
"name": "gender",
|
||||
"value": "male",
|
||||
"visibility": "PUBLIC"
|
||||
},
|
||||
{
|
||||
"name": "appearance",
|
||||
"value": "A tall human with short dark hair and alert eyes, standing near the center of the room.",
|
||||
@@ -96,6 +95,11 @@
|
||||
"visibility": "PRIVATE",
|
||||
"allowedEntities": ["bf3f29d2-cf11-4b11-9a99-b13c126d400e"]
|
||||
},
|
||||
{
|
||||
"name": "gender",
|
||||
"value": "male",
|
||||
"visibility": "PUBLIC"
|
||||
},
|
||||
{
|
||||
"name": "appearance",
|
||||
"value": "A medium-build human with long blonde hair tied back, sitting with their back pressed against the white wall.",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"build:web": "pnpm --filter landing build",
|
||||
"build:docs": "pnpm --filter docs build",
|
||||
"build:gui": "pnpm --filter @omnia/gui build",
|
||||
"build:all": "pnpm build && pnpm build:web && pnpm build:docs && pnpm build:gui",
|
||||
"dev:web": "pnpm --filter landing dev",
|
||||
"dev:docs": "pnpm --filter docs dev",
|
||||
"dev:gui": "pnpm --filter @omnia/gui dev",
|
||||
@@ -23,8 +22,8 @@
|
||||
"test:evals": "vitest run --project evals"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"author": "sortedcord",
|
||||
"license": "MIT",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
@@ -48,6 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/openrouter": "^0.4.3",
|
||||
"@types/node": "^20.19.43",
|
||||
"dotenv": "^17.4.2"
|
||||
}
|
||||
|
||||
@@ -59,21 +59,22 @@ export class ActorPromptBuilder {
|
||||
|
||||
private buildSystemPrompt(): string {
|
||||
return `
|
||||
You are an actor agent embodying a single character in a narrative simulation. You ARE this character — act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
|
||||
You are an actor agent embodying a single character in a narrative simulation. You ARE this character: act immersively, naturally, and in-character at all times. Do not break character, do not reference being an AI or a system, and do not narrate from outside the character's perspective.
|
||||
|
||||
Your output is a short block of narrative prose describing what your character does, says, or thinks next. You may:
|
||||
- Speak aloud → this becomes a "dialogue" intent. Other entities can hear it.
|
||||
- Perform a physical or logical action → this becomes an "action" intent. It is subject to the world's physics and will be validated by the World Architect.
|
||||
- Think internally / reflect / feel → this becomes a "monologue" intent. NO ONE else perceives it. It bypasses all validation and is written straight to your private memory. Use this for inner thoughts, doubts, plans, and feelings that you would not voice aloud.
|
||||
- Speak aloud → Other entities can hear it if they are present nearby. (Or nobody will hear it if you are alone)
|
||||
- Perform a physical action → It is subject to the world's physics and logic. Do not describe the outcome of your action.
|
||||
- Think internally / reflect / feel → this is a "monologue". NO ONE else perceives it. This is what you think internally.
|
||||
|
||||
Guidelines:
|
||||
- Always write in the first person (e.g., "I do this", "I say", "I think").
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment, the room, your surroundings, or other characters' actions, as these are managed by the simulation engine.
|
||||
- Stay strictly within what your character knows. If an attribute, entity, or fact is not present in your context below, your character does not know it — do not invent it or act on it.
|
||||
- Refer to other entities by the subjective names/aliases given in your context, never by raw system IDs.
|
||||
- Keep your prose vivid but concise. A single response may contain more than one intent (e.g., you may think, then speak, then act) — write them in natural narrative order.
|
||||
- Always write in the first person
|
||||
- Only describe your character's own actions, spoken words, and internal reactions. Do NOT narrate or describe the environment or your surroundings, or other characters' actions.
|
||||
- Refer to other entities by the subjective names/aliases that you refer to them as.
|
||||
- Keep your prose vivid but concise. Write it in natural narrative order.
|
||||
- Not every response requires an outward action. It is perfectly valid to only think (a monologue) and do nothing perceivable.
|
||||
- Never speak or act on another entity's behalf — you only control your own character.
|
||||
- Never speak or act on another entity's behalf. You only control your own character.
|
||||
- Stay strictly within what your character knows. Do not invent knowledge that doesn't exist or act on it.
|
||||
- You are limited by just your memory. If your memory is limited, then that's all you can remember. If you do make stuff up then that's lying. Which is allowed, but remember that you're lying.
|
||||
".
|
||||
`.trim();
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "open the chest and read the scroll",
|
||||
description: "Open the chest and read the scroll",
|
||||
selfDescription: "You open the chest and read the scroll.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.validateIntent(world, intent);
|
||||
@@ -51,8 +53,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "unlock the gate and escape",
|
||||
description: "Unlock the gate and escape",
|
||||
selfDescription: "You unlock the gate and escape.",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.validateIntent(world, intent);
|
||||
@@ -72,8 +76,10 @@ describe("Architect & LLMValidator Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "haunt the mansion",
|
||||
description: "Haunt the mansion",
|
||||
selfDescription: "You haunt the mansion.",
|
||||
actorId: "ghost",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.validateIntent(world, intent);
|
||||
@@ -110,8 +116,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
type: "action",
|
||||
originalText: "pick the lock of the wooden chest",
|
||||
description: "Pick the lock of the wooden chest",
|
||||
selfDescription: "You pick the lock of the wooden chest.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.processIntent(world, intent);
|
||||
@@ -152,8 +160,10 @@ describe("TimeDeltaGenerator & Architect.processIntent Unit Tests (Tier 1)", ()
|
||||
type: "action",
|
||||
originalText: "run away",
|
||||
description: "Run away",
|
||||
selfDescription: "You run away.",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const result = await architect.processIntent(world, intent);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WorldState, serializeObjectiveWorldState } from "@omnia/core";
|
||||
import { WorldState } from "@omnia/core";
|
||||
import { ILLMProvider } from "@omnia/llm";
|
||||
import { IntentSequence, IntentSequenceSchema } from "./intent.js";
|
||||
import { IntentSequence, LLMIntentSequenceSchema } from "./intent.js";
|
||||
|
||||
export class IntentDecoder {
|
||||
constructor(private llmProvider: ILLMProvider) {}
|
||||
@@ -23,9 +23,15 @@ export class IntentDecoder {
|
||||
const actor = worldState.getEntity(actorId);
|
||||
|
||||
const aliasEntries = actor ? Array.from(actor.aliases.entries()) : [];
|
||||
const aliasContext = aliasEntries.length > 0
|
||||
? aliasEntries.map(([targetId, alias]) => `- "${alias}" refers to entity ID: "${targetId}"`).join("\n")
|
||||
: "(No known aliases)";
|
||||
const aliasContext =
|
||||
aliasEntries.length > 0
|
||||
? aliasEntries
|
||||
.map(
|
||||
([targetId, alias]) =>
|
||||
`- "${alias}" refers to entity ID: "${targetId}"`,
|
||||
)
|
||||
.join("\n")
|
||||
: "(No known aliases)";
|
||||
|
||||
const systemPrompt = `
|
||||
You are the Intent Decoder for a narrative simulation engine.
|
||||
@@ -33,20 +39,16 @@ Your job is to take a block of narrative prose written by an actor agent and dec
|
||||
|
||||
For each intent you must:
|
||||
1. Classify its type:
|
||||
- "dialogue": Any speech, conversation, or verbal communication directed at another entity.
|
||||
- "action": Any physical or logical action performed in the world (e.g., moving, picking up, opening, looking).
|
||||
- "monologue": An inner thought, reflection, or internal monologue. This is purely internal — not spoken aloud, not perceivable by any other entity, and not a physical action. Use this for any prose depicting the character thinking, reflecting, feeling, or narrating to themselves internally.
|
||||
- "dialogue": if actor speaking, talking, whispering, murmuring, etc
|
||||
- "action": Any physical or logical action performed in the world (e.g., moving, opening, looking).
|
||||
- "monologue": An inner thought, reflection, or internal monologue/self narration.
|
||||
2. Extract the original text fragment from the prose that corresponds to this intent.
|
||||
3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
|
||||
4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
|
||||
5. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" and "ACTOR ALIASES" mapping to resolve any subjective names, descriptions, or nicknames used in the prose to their correct system entity IDs. If no specific target, use an empty array. For "monologue" intents, targetIds must always be an empty array.
|
||||
|
||||
Rules:
|
||||
- Preserve the chronological order of intents as they appear in the prose.
|
||||
- Do NOT merge unrelated actions into a single intent.
|
||||
- Dialogue and actions should be separate intents even if they happen in the same sentence.
|
||||
- If the prose contains only dialogue, return a single dialogue intent.
|
||||
- If the prose contains only a single action, return a single action intent.
|
||||
3. Populate "description" and "selfDescription":
|
||||
- "description": No subject or name — a bare third-person verb phrase only (e.g. "clears their throat", "shakes their head slowly")
|
||||
- "selfDescription": The same event from the actor's own perspective, second person, complete sentence starting with "You" (e.g. "You clear your throat.", "You shake your head slowly."). This is shown directly in the actor's own memory — it must never say "the actor" or refer to them in the third person.
|
||||
- In case of a dialogue, the description and self Description only stores the exact words said by the entity. (e.g. "I will do that later", "Are you serious right now?")
|
||||
4. Identify targetIds — the entity IDs of the receiving parties. Use the "KNOWN ENTITY IDS" mapping to resolve any subjective names,or aliases used in the prose to their correct system entity IDs. If no specific target, use an empty array.
|
||||
5. Identify modifiers — a list of strings representing additional qualities or modifiers extracted from the narrative prose. This includes emotions, tone of voice, speed, manner of action, or statement type (e.g., "question", "anxious", "whispering", "slowly", "quietly", "forcefully"). If no modifiers are present, use an empty array.
|
||||
`.trim();
|
||||
|
||||
const userContext = `
|
||||
@@ -58,7 +60,7 @@ The actor refers to other entities using these subjective names/aliases:
|
||||
${aliasContext}
|
||||
|
||||
=== WORLD STATE ===
|
||||
${serializeObjectiveWorldState(worldState)}
|
||||
${serializeSimplifiedWorldState(worldState)}
|
||||
|
||||
=== ACTOR ===
|
||||
Actor ID: ${actorId}
|
||||
@@ -70,7 +72,7 @@ ${narrativeProse}
|
||||
const response = await this.llmProvider.generateStructuredResponse({
|
||||
systemPrompt,
|
||||
userContext,
|
||||
schema: IntentSequenceSchema,
|
||||
schema: LLMIntentSequenceSchema,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
@@ -79,6 +81,42 @@ ${narrativeProse}
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
const fullIntents = response.data.intents.map((intent) => ({
|
||||
...intent,
|
||||
actorId,
|
||||
}));
|
||||
|
||||
return {
|
||||
intents: fullIntents,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSimplifiedWorldState(worldState: WorldState): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("Locations:");
|
||||
if (worldState.locations.size > 0) {
|
||||
for (const loc of worldState.locations.values()) {
|
||||
const parentId = (loc as { parentId?: string | null }).parentId;
|
||||
const parentStr = parentId ? ` (Parent: ${parentId})` : "";
|
||||
lines.push(` - Location [ID: ${loc.id}]${parentStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No locations)");
|
||||
}
|
||||
|
||||
lines.push("Entities:");
|
||||
if (worldState.entities.size > 0) {
|
||||
for (const entity of worldState.entities.values()) {
|
||||
const locStr = entity.locationId
|
||||
? ` (Location: ${entity.locationId})`
|
||||
: "";
|
||||
lines.push(` - Entity [ID: ${entity.id}]${locStr}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(" (No entities)");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export type IntentType = z.infer<typeof IntentTypeSchema>;
|
||||
/**
|
||||
* A single decoded intent extracted from narrative prose.
|
||||
*/
|
||||
export const IntentSchema = z.object({
|
||||
export const LLMIntentSchema = z.object({
|
||||
/** The type of intent. */
|
||||
type: IntentTypeSchema,
|
||||
|
||||
@@ -24,8 +24,8 @@ export const IntentSchema = z.object({
|
||||
/** A concise, structured description of the intent's action or dialogue. */
|
||||
description: z.string(),
|
||||
|
||||
/** The entity ID of the actor performing the intent. */
|
||||
actorId: z.string(),
|
||||
/** The same event from the actor's own perspective (second person, "You"). */
|
||||
selfDescription: z.string(),
|
||||
|
||||
/**
|
||||
* Entity IDs of the receiving parties (e.g., who is being spoken to,
|
||||
@@ -33,10 +33,25 @@ export const IntentSchema = z.object({
|
||||
* "monologue" intents, since they are not perceivable by anyone.
|
||||
*/
|
||||
targetIds: z.array(z.string()),
|
||||
|
||||
/**
|
||||
* Additional qualities or modifiers extracted from the prose (e.g., emotions,
|
||||
* questions, speed, manner of action like 'quietly', 'whispering', 'anxiously').
|
||||
*/
|
||||
modifiers: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const IntentSchema = LLMIntentSchema.extend({
|
||||
/** The entity ID of the actor performing the intent. */
|
||||
actorId: z.string(),
|
||||
});
|
||||
|
||||
export type Intent = z.infer<typeof IntentSchema>;
|
||||
|
||||
export const LLMIntentSequenceSchema = z.object({
|
||||
intents: z.array(LLMIntentSchema),
|
||||
});
|
||||
|
||||
/**
|
||||
* The full output of the Intent Decoder: an ordered sequence of intents
|
||||
* extracted from a single narrative prose block.
|
||||
|
||||
@@ -15,8 +15,9 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "Alice opened the chest.",
|
||||
description: "Open the wooden chest.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You open the wooden chest.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -45,8 +46,9 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
type: "dialogue",
|
||||
originalText: '"Do you have the key?" Alice asked Bob.',
|
||||
description: "Alice asks Bob if he has the key.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You ask Bob if he has the key.",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -78,15 +80,17 @@ describe("IntentDecoder Unit Tests (Tier 1)", () => {
|
||||
type: "dialogue",
|
||||
originalText: '"Cover me," Alice whispered to Bob.',
|
||||
description: "Alice whispers to Bob requesting cover.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You whisper to Bob requesting cover.",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She crept towards the door and pulled the handle.",
|
||||
description: "Creep towards the door and pull the handle.",
|
||||
actorId: "alice",
|
||||
selfDescription: "You creep towards the door and pull the handle.",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
|
||||
const LLMConfigSchema = z.object({
|
||||
GOOGLE_API_KEY: z.string().optional(),
|
||||
OPENROUTER_API_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const llmConfig = LLMConfigSchema.parse(process.env);
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from "./llm.js";
|
||||
export * from "./config.js";
|
||||
export * from "./providers/google-genai.js";
|
||||
export * from "./providers/mock.js";
|
||||
export * from "./providers/openrouter.js";
|
||||
export * from "./provider-manager.js";
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface LLMResponse<T> {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +27,8 @@ export interface LLMCallRecord {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
modelName?: string;
|
||||
providerInstanceName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,3 +49,31 @@ export interface LLMProviderInstance {
|
||||
isActive: boolean;
|
||||
modelName?: string;
|
||||
}
|
||||
|
||||
export interface LLMProviderMeta {
|
||||
id: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
defaultModel: string;
|
||||
}
|
||||
|
||||
export const AVAILABLE_PROVIDERS: LLMProviderMeta[] = [
|
||||
{
|
||||
id: "google-genai",
|
||||
displayName: "Google Gemini",
|
||||
description: "Official Gemini integration using Google Gen AI SDK",
|
||||
defaultModel: "gemini-2.5-flash",
|
||||
},
|
||||
{
|
||||
id: "openrouter",
|
||||
displayName: "OpenRouter",
|
||||
description: "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models",
|
||||
defaultModel: "google/gemini-2.5-flash",
|
||||
},
|
||||
{
|
||||
id: "mock",
|
||||
displayName: "Mock LLM Provider",
|
||||
description: "Stateless mock provider for testing and offline development",
|
||||
defaultModel: "mock",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,6 +3,17 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import type { LLMProviderInstance } from "./llm.js";
|
||||
|
||||
let dbPathOverride: string | null = null;
|
||||
let hasBootstrapped = false;
|
||||
|
||||
export function setDbPathOverride(p: string | null) {
|
||||
dbPathOverride = p;
|
||||
}
|
||||
|
||||
export function resetHasBootstrapped() {
|
||||
hasBootstrapped = false;
|
||||
}
|
||||
|
||||
function getWorkspaceRoot() {
|
||||
let current = process.cwd();
|
||||
while (current !== "/" && current !== path.parse(current).root) {
|
||||
@@ -20,12 +31,17 @@ function getWorkspaceRoot() {
|
||||
}
|
||||
|
||||
function getSettingsDb() {
|
||||
const wsRoot = getWorkspaceRoot();
|
||||
const dbDir = path.resolve(wsRoot, "data");
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
let dbPath: string;
|
||||
if (dbPathOverride) {
|
||||
dbPath = dbPathOverride;
|
||||
} else {
|
||||
const wsRoot = getWorkspaceRoot();
|
||||
const dbDir = path.resolve(wsRoot, "data");
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
dbPath = path.join(dbDir, "settings.db");
|
||||
}
|
||||
const dbPath = path.join(dbDir, "settings.db");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.prepare(`
|
||||
@@ -44,6 +60,39 @@ function getSettingsDb() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Auto-bootstrap environment variables if DB contains 0 instances
|
||||
try {
|
||||
if (!hasBootstrapped) {
|
||||
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
|
||||
if (totalCount.count === 0) {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
let hasInserted = false;
|
||||
|
||||
if (googleKey && googleKey.trim()) {
|
||||
const id = "provider-default-google";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Gemini (Env)", "google-genai", googleKey.trim(), 1, "gemini-2.5-flash");
|
||||
hasInserted = true;
|
||||
}
|
||||
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
const id = "provider-default-openrouter";
|
||||
const isActive = hasInserted ? 0 : 1;
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "OpenRouter (Env)", "openrouter", openRouterKey.trim(), isActive, "google/gemini-2.5-flash");
|
||||
}
|
||||
}
|
||||
hasBootstrapped = true;
|
||||
}
|
||||
} catch {
|
||||
// ignore write lock issues or other DB errors during bootstrap
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
@@ -153,28 +202,25 @@ export class ProviderManager {
|
||||
} | undefined;
|
||||
|
||||
if (!row) {
|
||||
// Check if there are any rows at all
|
||||
const totalCount = db.prepare(`SELECT COUNT(*) as count FROM provider_instances`).get() as { count: number };
|
||||
if (totalCount.count === 0) {
|
||||
// Database is completely empty! Check if GOOGLE_API_KEY env is set.
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey && envKey.trim()) {
|
||||
// Auto-bootstrap default active instance from env
|
||||
const id = "provider-default-env";
|
||||
db.prepare(`
|
||||
INSERT INTO provider_instances (id, name, providerName, apiKey, isActive, modelName)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, "Default (Env)", "google-genai", envKey, 1, "gemini-2.5-flash");
|
||||
|
||||
return {
|
||||
id,
|
||||
name: "Default (Env)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
isActive: true,
|
||||
modelName: "gemini-2.5-flash",
|
||||
};
|
||||
}
|
||||
// If there's no active row but some rows exist, return the first one as active, or update it
|
||||
const firstRow = db.prepare(`SELECT * FROM provider_instances LIMIT 1`).get() as {
|
||||
id: string;
|
||||
name: string;
|
||||
providerName: string;
|
||||
apiKey: string;
|
||||
isActive: number;
|
||||
modelName?: string;
|
||||
} | undefined;
|
||||
if (firstRow) {
|
||||
db.prepare(`UPDATE provider_instances SET isActive = 1 WHERE id = ?`).run(firstRow.id);
|
||||
return {
|
||||
id: firstRow.id,
|
||||
name: firstRow.name,
|
||||
providerName: firstRow.providerName,
|
||||
apiKey: firstRow.apiKey,
|
||||
isActive: true,
|
||||
modelName: firstRow.modelName || undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -189,17 +235,28 @@ export class ProviderManager {
|
||||
};
|
||||
} catch {
|
||||
// Lock or write issue fallback: return an in-memory active key if env key exists
|
||||
const envKey = process.env.GOOGLE_API_KEY;
|
||||
if (envKey) {
|
||||
const googleKey = process.env.GOOGLE_API_KEY;
|
||||
if (googleKey && googleKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-fallback",
|
||||
name: "Default (Env Fallback)",
|
||||
name: "Gemini (Env Fallback)",
|
||||
providerName: "google-genai",
|
||||
apiKey: envKey,
|
||||
apiKey: googleKey.trim(),
|
||||
isActive: true,
|
||||
modelName: "gemini-2.5-flash",
|
||||
};
|
||||
}
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (openRouterKey && openRouterKey.trim()) {
|
||||
return {
|
||||
id: "provider-default-env-fallback",
|
||||
name: "OpenRouter (Env Fallback)",
|
||||
providerName: "openrouter",
|
||||
apiKey: openRouterKey.trim(),
|
||||
isActive: true,
|
||||
modelName: "google/gemini-2.5-flash",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
@@ -5,35 +5,50 @@ import { llmConfig } from "../config.js";
|
||||
import { ProviderManager } from "../provider-manager.js";
|
||||
|
||||
export class GeminiProvider implements ILLMProvider {
|
||||
static readonly providerId = "google-genai";
|
||||
static readonly displayName = "Google Gemini";
|
||||
static readonly description = "Official Gemini integration using Google Gen AI SDK";
|
||||
static readonly defaultModel = "gemini-2.5-flash";
|
||||
|
||||
providerName = "Gemini";
|
||||
private model: ChatGoogleGenerativeAI;
|
||||
private modelNameUsed: string;
|
||||
private providerInstanceName?: string;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(apiKey?: string, modelName?: string) {
|
||||
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive();
|
||||
if (active) {
|
||||
if (active && active.providerName === GeminiProvider.providerId) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
if (!this.providerInstanceName) {
|
||||
this.providerInstanceName = active.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.GOOGLE_API_KEY;
|
||||
if (!this.providerInstanceName && key) {
|
||||
this.providerInstanceName = "Environment Variable";
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error("GOOGLE_API_KEY is required to initialize GeminiProvider");
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || "gemini-2.5-flash";
|
||||
this.model = new ChatGoogleGenerativeAI({
|
||||
apiKey: key,
|
||||
model: model || "gemini-2.5-flash",
|
||||
model: this.modelNameUsed,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,11 +73,13 @@ export class GeminiProvider implements ILLMProvider {
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = raw?.usage_metadata ? {
|
||||
inputTokens: raw.usage_metadata.input_tokens || 0,
|
||||
outputTokens: raw.usage_metadata.output_tokens || 0,
|
||||
totalTokens: raw.usage_metadata.total_tokens || 0,
|
||||
} : undefined;
|
||||
const usage = {
|
||||
inputTokens: raw?.usage_metadata?.input_tokens || 0,
|
||||
outputTokens: raw?.usage_metadata?.output_tokens || 0,
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
|
||||
@@ -2,6 +2,11 @@ import { z } from "zod";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
|
||||
|
||||
export class MockLLMProvider implements ILLMProvider {
|
||||
static readonly providerId = "mock";
|
||||
static readonly displayName = "Mock LLM Provider";
|
||||
static readonly description = "Stateless mock provider for testing and offline development";
|
||||
static readonly defaultModel = "mock";
|
||||
|
||||
providerName = "mock";
|
||||
private callCount = 0;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
92
packages/llm/src/providers/openrouter.ts
Normal file
92
packages/llm/src/providers/openrouter.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { z } from "zod";
|
||||
import { ChatOpenRouter } from "@langchain/openrouter";
|
||||
import { ILLMProvider, LLMRequest, LLMResponse, LLMCallRecord } from "../llm.js";
|
||||
import { llmConfig } from "../config.js";
|
||||
import { ProviderManager } from "../provider-manager.js";
|
||||
|
||||
export class OpenRouterProvider implements ILLMProvider {
|
||||
static readonly providerId = "openrouter";
|
||||
static readonly displayName = "OpenRouter";
|
||||
static readonly description = "Multi-model router supporting Anthropic, OpenAI, DeepSeek, and local models";
|
||||
static readonly defaultModel = "google/gemini-2.5-flash";
|
||||
|
||||
providerName = "OpenRouter";
|
||||
private model: ChatOpenRouter;
|
||||
private modelNameUsed: string;
|
||||
private providerInstanceName?: string;
|
||||
lastCalls: LLMCallRecord[] = [];
|
||||
|
||||
constructor(apiKey?: string, modelName?: string, providerInstanceName?: string) {
|
||||
let key = apiKey;
|
||||
let model = modelName;
|
||||
this.providerInstanceName = providerInstanceName;
|
||||
|
||||
if (!key) {
|
||||
const active = ProviderManager.getActive();
|
||||
if (active && active.providerName === OpenRouterProvider.providerId) {
|
||||
key = active.apiKey;
|
||||
if (!model) {
|
||||
model = active.modelName;
|
||||
}
|
||||
if (!this.providerInstanceName) {
|
||||
this.providerInstanceName = active.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
key = llmConfig.OPENROUTER_API_KEY;
|
||||
if (!this.providerInstanceName && key) {
|
||||
this.providerInstanceName = "Environment Variable";
|
||||
}
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
throw new Error("OPENROUTER_API_KEY is required to initialize OpenRouterProvider");
|
||||
}
|
||||
|
||||
this.modelNameUsed = model || "google/gemini-2.5-flash";
|
||||
this.model = new ChatOpenRouter({
|
||||
apiKey: key,
|
||||
model: this.modelNameUsed,
|
||||
});
|
||||
}
|
||||
|
||||
async generateStructuredResponse<T extends z.ZodTypeAny>(
|
||||
request: LLMRequest<T>,
|
||||
): Promise<LLMResponse<z.infer<T>>> {
|
||||
const structuredModel = this.model.withStructuredOutput(request.schema, { includeRaw: true });
|
||||
const result = (await structuredModel.invoke([
|
||||
{ role: "system", content: request.systemPrompt },
|
||||
{ role: "user", content: request.userContext },
|
||||
])) as unknown as {
|
||||
parsed?: z.infer<T>;
|
||||
raw?: {
|
||||
usage_metadata?: {
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const parsed = result?.parsed;
|
||||
const raw = result?.raw;
|
||||
|
||||
const usage = {
|
||||
inputTokens: raw?.usage_metadata?.input_tokens || 0,
|
||||
outputTokens: raw?.usage_metadata?.output_tokens || 0,
|
||||
totalTokens: raw?.usage_metadata?.total_tokens || 0,
|
||||
modelName: this.modelNameUsed,
|
||||
providerInstanceName: this.providerInstanceName || "Default",
|
||||
};
|
||||
|
||||
this.lastCalls.push({
|
||||
systemPrompt: request.systemPrompt,
|
||||
userContext: request.userContext,
|
||||
usage,
|
||||
});
|
||||
|
||||
return { success: true, data: parsed, usage };
|
||||
}
|
||||
}
|
||||
111
packages/llm/tests/openrouter.test.ts
Normal file
111
packages/llm/tests/openrouter.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, test, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { OpenRouterProvider } from "../src/providers/openrouter.js";
|
||||
import { llmConfig } from "../src/config.js";
|
||||
|
||||
// Mock the ChatOpenRouter class
|
||||
vi.mock("@langchain/openrouter", () => {
|
||||
return {
|
||||
ChatOpenRouter: class {
|
||||
config: unknown;
|
||||
constructor(config: unknown) {
|
||||
this.config = config;
|
||||
}
|
||||
withStructuredOutput = vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
invoke: vi.fn().mockImplementation(async () => {
|
||||
// Return a mock output that matches the includeRaw: true structure
|
||||
return {
|
||||
parsed: {
|
||||
name: "mocked response",
|
||||
success: true,
|
||||
},
|
||||
raw: {
|
||||
usage_metadata: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("OpenRouterProvider Unit Tests (Tier 1)", () => {
|
||||
test("initializes successfully with a provided apiKey", () => {
|
||||
const provider = new OpenRouterProvider("dummy-key");
|
||||
expect(provider.providerName).toBe("OpenRouter");
|
||||
});
|
||||
|
||||
test("initializes successfully with apiKey from config", () => {
|
||||
// Save current config
|
||||
const originalKey = llmConfig.OPENROUTER_API_KEY;
|
||||
llmConfig.OPENROUTER_API_KEY = "env-dummy-key";
|
||||
|
||||
try {
|
||||
const provider = new OpenRouterProvider();
|
||||
expect(provider.providerName).toBe("OpenRouter");
|
||||
} finally {
|
||||
llmConfig.OPENROUTER_API_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("throws error if no API key is provided or in config", () => {
|
||||
// Save current config
|
||||
const originalKey = llmConfig.OPENROUTER_API_KEY;
|
||||
llmConfig.OPENROUTER_API_KEY = undefined;
|
||||
|
||||
try {
|
||||
expect(() => new OpenRouterProvider()).toThrow(
|
||||
"OPENROUTER_API_KEY is required to initialize OpenRouterProvider"
|
||||
);
|
||||
} finally {
|
||||
llmConfig.OPENROUTER_API_KEY = originalKey;
|
||||
}
|
||||
});
|
||||
|
||||
test("generateStructuredResponse invokes the model with structured output, records usage and updates lastCalls", async () => {
|
||||
const provider = new OpenRouterProvider("dummy-key");
|
||||
const TestSchema = z.object({
|
||||
name: z.string(),
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
const response = await provider.generateStructuredResponse({
|
||||
systemPrompt: "system prompt",
|
||||
userContext: "user context",
|
||||
schema: TestSchema,
|
||||
});
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.data).toEqual({
|
||||
name: "mocked response",
|
||||
success: true,
|
||||
});
|
||||
|
||||
expect(response.usage).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
modelName: "google/gemini-2.5-flash",
|
||||
providerInstanceName: "Default",
|
||||
});
|
||||
|
||||
expect(provider.lastCalls.length).toBe(1);
|
||||
expect(provider.lastCalls[0]).toEqual({
|
||||
systemPrompt: "system prompt",
|
||||
userContext: "user context",
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 15,
|
||||
modelName: "google/gemini-2.5-flash",
|
||||
providerInstanceName: "Default",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
91
packages/llm/tests/provider-manager.test.ts
Normal file
91
packages/llm/tests/provider-manager.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ProviderManager, setDbPathOverride, resetHasBootstrapped } from "../src/index.js";
|
||||
|
||||
describe("ProviderManager Bootstrapping & CRUD Unit Tests", () => {
|
||||
let tempDbPath: string;
|
||||
let originalGoogle: string | undefined;
|
||||
let originalOpenRouter: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalGoogle = process.env.GOOGLE_API_KEY;
|
||||
originalOpenRouter = process.env.OPENROUTER_API_KEY;
|
||||
delete process.env.GOOGLE_API_KEY;
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
|
||||
resetHasBootstrapped();
|
||||
|
||||
// Generate a unique temp database path for this test run
|
||||
tempDbPath = path.resolve(process.cwd(), `test-settings-${Date.now()}-${Math.random().toString(36).substring(2)}.db`);
|
||||
setDbPathOverride(tempDbPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setDbPathOverride(null);
|
||||
if (fs.existsSync(tempDbPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempDbPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (originalGoogle !== undefined) {
|
||||
process.env.GOOGLE_API_KEY = originalGoogle;
|
||||
} else {
|
||||
delete process.env.GOOGLE_API_KEY;
|
||||
}
|
||||
if (originalOpenRouter !== undefined) {
|
||||
process.env.OPENROUTER_API_KEY = originalOpenRouter;
|
||||
} else {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("auto-bootstraps Gemini and OpenRouter when database is empty and environment variables are present", () => {
|
||||
process.env.GOOGLE_API_KEY = "mock-google-key-123";
|
||||
process.env.OPENROUTER_API_KEY = "mock-openrouter-key-456";
|
||||
|
||||
const list = ProviderManager.list();
|
||||
expect(list.length).toBe(2);
|
||||
|
||||
const gemini = list.find((p) => p.providerName === "google-genai");
|
||||
expect(gemini).toBeDefined();
|
||||
expect(gemini?.name).toBe("Gemini (Env)");
|
||||
expect(gemini?.apiKey).toBe("mock-google-key-123");
|
||||
expect(gemini?.modelName).toBe("gemini-2.5-flash");
|
||||
expect(gemini?.isActive).toBe(true); // first inserted is active
|
||||
|
||||
const openrouter = list.find((p) => p.providerName === "openrouter");
|
||||
expect(openrouter).toBeDefined();
|
||||
expect(openrouter?.name).toBe("OpenRouter (Env)");
|
||||
expect(openrouter?.apiKey).toBe("mock-openrouter-key-456");
|
||||
expect(openrouter?.modelName).toBe("google/gemini-2.5-flash");
|
||||
expect(openrouter?.isActive).toBe(false); // second inserted is inactive
|
||||
});
|
||||
|
||||
test("treats bootstrapped instances as normal provider instances (editable and deletable)", () => {
|
||||
process.env.GOOGLE_API_KEY = "mock-google-key-123";
|
||||
|
||||
// Trigger bootstrap
|
||||
const list = ProviderManager.list();
|
||||
expect(list.length).toBe(1);
|
||||
const bootstrapped = list[0];
|
||||
expect(bootstrapped.name).toBe("Gemini (Env)");
|
||||
expect(bootstrapped.isActive).toBe(true);
|
||||
|
||||
// Edit name and key
|
||||
ProviderManager.update(bootstrapped.id, "My Gemini Key", "google-genai", "new-secret-key", "gemini-2.5-pro");
|
||||
|
||||
const listAfterUpdate = ProviderManager.list();
|
||||
expect(listAfterUpdate.length).toBe(1);
|
||||
expect(listAfterUpdate[0].name).toBe("My Gemini Key");
|
||||
expect(listAfterUpdate[0].apiKey).toBe("new-secret-key");
|
||||
expect(listAfterUpdate[0].modelName).toBe("gemini-2.5-pro");
|
||||
|
||||
// Delete instance
|
||||
ProviderManager.delete(bootstrapped.id);
|
||||
const listAfterDelete = ProviderManager.list();
|
||||
expect(listAfterDelete.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -25,27 +25,28 @@ export function serializeSubjectiveBufferEntry(
|
||||
entry: BufferEntry,
|
||||
viewer: Entity,
|
||||
): string {
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
const isSelf = viewer.id === entry.intent.actorId;
|
||||
|
||||
const targetAliases = entry.intent.targetIds.map((tid) =>
|
||||
resolveAlias(viewer, tid),
|
||||
);
|
||||
|
||||
let details: string;
|
||||
const content = entry.intent.description.trim() || entry.intent.originalText.trim();
|
||||
|
||||
if (entry.intent.type === "dialogue") {
|
||||
details = `spoke to ${targetAliases.join(", ") || "someone"}: "${content}"`;
|
||||
} else if (entry.intent.type === "monologue") {
|
||||
details = `thought: "${content}"`;
|
||||
} else {
|
||||
details = content;
|
||||
if (entry.outcome) {
|
||||
if (isSelf) {
|
||||
let details = (entry.intent.selfDescription || entry.intent.description || entry.intent.originalText).trim();
|
||||
if (details.length > 0) {
|
||||
details = details.charAt(0).toUpperCase() + details.slice(1);
|
||||
}
|
||||
if (entry.intent.type === "action" && entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
return `${actorAlias} ${details}`;
|
||||
const actorAlias = resolveAlias(viewer, entry.intent.actorId);
|
||||
const subjectStr = actorAlias.charAt(0).toUpperCase() + actorAlias.slice(1);
|
||||
|
||||
let details = (entry.intent.description || entry.intent.originalText).trim();
|
||||
if (entry.intent.type === "action" && entry.outcome) {
|
||||
details += ` (Outcome: ${entry.outcome.isValid ? "Succeeded" : `Failed - ${entry.outcome.reason}`})`;
|
||||
}
|
||||
|
||||
return `${subjectStr} ${details}`;
|
||||
}
|
||||
|
||||
export class BufferRepository {
|
||||
|
||||
@@ -32,14 +32,16 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
intent: {
|
||||
type: "dialogue",
|
||||
originalText: '"Hello there," Bob said to Charlie.',
|
||||
description: "Bob greets Charlie",
|
||||
description: "says, 'Hello there' to the bartender",
|
||||
selfDescription: "You say, 'Hello there' to the bartender.",
|
||||
actorId: "bob",
|
||||
targetIds: ["charlie"],
|
||||
modifiers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('the hooded figure spoke to the bartender: "Bob greets Charlie"');
|
||||
expect(result).toBe("The hooded figure says, 'Hello there' to the bartender");
|
||||
});
|
||||
|
||||
test("serializes action intent with outcome details", () => {
|
||||
@@ -54,9 +56,11 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Bob tried to break the latch.",
|
||||
description: "Bob attempts to break the lock latch",
|
||||
description: "attempts to break the lock latch",
|
||||
selfDescription: "You attempt to break the lock latch.",
|
||||
actorId: "bob",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
outcome: {
|
||||
isValid: false,
|
||||
@@ -65,7 +69,7 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
};
|
||||
|
||||
const result = serializeSubjectiveBufferEntry(entry, viewer);
|
||||
expect(result).toBe('the hooded figure Bob attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
|
||||
expect(result).toBe('The hooded figure attempts to break the lock latch (Outcome: Failed - The lock is made of reinforced steel.)');
|
||||
});
|
||||
|
||||
test("serializes self-reference and unfamiliar actors", () => {
|
||||
@@ -80,13 +84,15 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "I opened the window.",
|
||||
description: "open the window",
|
||||
selfDescription: "You open the window.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultSelf = serializeSubjectiveBufferEntry(entrySelf, viewer);
|
||||
expect(resultSelf).toBe("you open the window");
|
||||
expect(resultSelf).toBe("You open the window.");
|
||||
|
||||
const entryUnfamiliar: BufferEntry = {
|
||||
id: "entry-unfamiliar",
|
||||
@@ -96,14 +102,16 @@ describe("Subjective Buffer Entry Serializer Tests (Tier 1)", () => {
|
||||
intent: {
|
||||
type: "action",
|
||||
originalText: "Someone knocked.",
|
||||
description: "knock on the door",
|
||||
description: "knocks on the door",
|
||||
selfDescription: "You knock on the door.",
|
||||
actorId: "stranger-1",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const resultUnfamiliar = serializeSubjectiveBufferEntry(entryUnfamiliar, viewer);
|
||||
expect(resultUnfamiliar).toBe("an unfamiliar figure knock on the door");
|
||||
expect(resultUnfamiliar).toBe("An unfamiliar figure knocks on the door");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,8 +131,10 @@ describe("BufferRepository Persistence Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "Alice picked up a stick.",
|
||||
description: "Alice gathers a stick",
|
||||
selfDescription: "You gather a stick.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const entry: BufferEntry = {
|
||||
|
||||
@@ -57,8 +57,10 @@ describe("Scenario Validation & Schema Tests (Tier 1)", () => {
|
||||
type: "action",
|
||||
originalText: "I entered the foyer.",
|
||||
description: "entered the house",
|
||||
selfDescription: "You entered the house.",
|
||||
actorId: "investigator",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
1680
pnpm-lock.yaml
generated
1680
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
workerd: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@astrojs/telemetry@3.3.3'
|
||||
- astro@7.0.7
|
||||
|
||||
@@ -62,22 +62,28 @@ describe("Actor Agent + Monologue Intent Integration (Tier 2)", () => {
|
||||
type: "monologue",
|
||||
originalText: "I can't believe Bob hasn't noticed me yet, Alice thought.",
|
||||
description: "Alice internally reflects that Bob has not noticed her.",
|
||||
selfDescription: "You internally reflect that Bob has not noticed you.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "dialogue",
|
||||
originalText: '"Hey Bob," she called out softly.',
|
||||
description: "Alice softly calls out to Bob.",
|
||||
selfDescription: "You softly call out to Bob.",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She reached for the ledger on the table.",
|
||||
description: "Alice reaches for the ledger on the table.",
|
||||
selfDescription: "You reach for the ledger on the table.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -33,15 +33,19 @@ describe("Omnia Integration Tests (Tier 2)", () => {
|
||||
type: "dialogue",
|
||||
originalText: '"Cover me," Alice whispered to Bob.',
|
||||
description: "Alice whispers to Bob to cover her.",
|
||||
selfDescription: "You whisper to Bob to cover you.",
|
||||
actorId: "alice",
|
||||
targetIds: ["bob"],
|
||||
modifiers: [],
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
originalText: "She crept towards the door and pulled the handle.",
|
||||
description: "Alice creeps to the door and pulls the handle.",
|
||||
selfDescription: "You creep to the door and pull the handle.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -108,16 +112,20 @@ describe("Omnia Integration Tests (Tier 2)", () => {
|
||||
type: "action" as const,
|
||||
originalText: "She tries to unlock the gate with a hairpin.",
|
||||
description: "Alice attempts to pick the lock with a hairpin.",
|
||||
selfDescription: "You attempt to pick the lock with a hairpin.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
const intent2 = {
|
||||
type: "dialogue" as const,
|
||||
originalText: '"This is useless," she mutters.',
|
||||
description: "Alice mutters to herself.",
|
||||
selfDescription: "You mutter to yourself.",
|
||||
actorId: "alice",
|
||||
targetIds: [],
|
||||
modifiers: [],
|
||||
};
|
||||
|
||||
// LLM validation / time delta mock responses:
|
||||
|
||||
@@ -5,10 +5,12 @@ import mermaid from "astro-mermaid";
|
||||
export default defineConfig({
|
||||
site: "https://omnia.omniasimulation.com",
|
||||
base: "/docs",
|
||||
outDir: "./dist/docs",
|
||||
integrations: [
|
||||
mermaid(),
|
||||
starlight({
|
||||
title: "Omnia Docs",
|
||||
favicon: "/favicon.png",
|
||||
logo: {
|
||||
src: "./src/assets/img/logo.png",
|
||||
replacesTitle: true,
|
||||
|
||||
@@ -7,11 +7,17 @@
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
"preview": "astro preview",
|
||||
"preview:wrangler": "astro build && wrangler dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/starlight": "^0.41.3",
|
||||
"astro": "^7.0.7",
|
||||
"astro-mermaid": "^2.1.0",
|
||||
"mermaid": "^11.16.0",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.107.1"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
web/docs/public/favicon.png
Normal file
BIN
web/docs/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 919 B |
BIN
web/docs/src/assets/img/features.webp
Normal file
BIN
web/docs/src/assets/img/features.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
BIN
web/docs/src/assets/img/icon.png
Normal file
BIN
web/docs/src/assets/img/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 919 B |
BIN
web/docs/src/assets/img/puppet.webp
Normal file
BIN
web/docs/src/assets/img/puppet.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -21,9 +21,10 @@ export interface ILLMProvider {
|
||||
}
|
||||
```
|
||||
|
||||
The codebase provides two primary implementations:
|
||||
The codebase provides three primary implementations:
|
||||
1. **`GeminiProvider`:** The production provider utilizing Google's Gemini Models via the `@langchain/google-genai` SDK.
|
||||
2. **`MockLLMProvider`:** A stateless, pre-programmed mock provider used for fast, deterministic unit testing and local integration tests.
|
||||
2. **`OpenRouterProvider`:** The production provider utilizing OpenRouter via the `@langchain/openrouter` SDK, allowing routing through various third-party and local models.
|
||||
3. **`MockLLMProvider`:** A stateless, pre-programmed mock provider used for fast, deterministic unit testing and local integration tests.
|
||||
|
||||
---
|
||||
|
||||
@@ -43,10 +44,10 @@ export interface LLMProviderInstance {
|
||||
```
|
||||
|
||||
Users can register multiple provider instances in the **Configuration Page** under the GUI. Each instance is given:
|
||||
* A friendly, human-readable name (e.g., `"Gemini Production Key"`, `"Experimental Gemini Pro"`).
|
||||
* A provider type (e.g., `google-genai`, `mock`).
|
||||
* A friendly, human-readable name (e.g., `"Gemini Production Key"`, `"OpenRouter Claude Key"`).
|
||||
* A provider type (e.g., `google-genai`, `openrouter`, `mock`).
|
||||
* An API key credential.
|
||||
* A custom target model name (e.g., `gemini-2.5-flash` or `gemini-2.5-pro`).
|
||||
* A custom target model name (e.g., `gemini-2.5-flash`, `anthropic/claude-3-5-sonnet`, or local model paths).
|
||||
* An **Active** status flag (one key is marked as globally active).
|
||||
|
||||
Configurations are stored globally in `data/settings.db` (separated from specific simulation run databases like `data/sim-*.db` to keep key storage and audit logs isolated).
|
||||
@@ -59,10 +60,10 @@ During a simulation run, the engine executes four distinct LLM operations. To op
|
||||
|
||||
| Task Name | Key ID | Description | Default Model |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Actor Prose Generation** | `actor-prose` | Generates roleplay and narrative behavioral prose for Non-Player Characters. | `gemini-2.5-flash` |
|
||||
| **LLM Validator** | `llm-validator` | Arbitrates and validates proposed actions against the world state rules and constraints. | `gemini-2.5-flash` |
|
||||
| **Intent Decoder** | `intent-decoder` | Parses and splits free-text actions/prose into structured intent sequences. | `gemini-2.5-flash` |
|
||||
| **TimeDelta Generator** | `timedelta` | Calculates the duration of character actions to advance the game clock. | `gemini-2.5-flash` |
|
||||
| **Actor Prose Generation** | `actor-prose` | Generates roleplay and narrative behavioral prose for Non-Player Characters. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
|
||||
| **LLM Validator** | `llm-validator` | Arbitrates and validates proposed actions against the world state rules and constraints. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
|
||||
| **Intent Decoder** | `intent-decoder` | Parses and splits free-text actions/prose into structured intent sequences. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
|
||||
| **TimeDelta Generator** | `timedelta` | Calculates the duration of character actions to advance the game clock. | `gemini-2.5-flash` / `google/gemini-2.5-flash` |
|
||||
|
||||
If no specific provider instance is mapped to a task, the task automatically routes to the globally marked **Active** provider instance.
|
||||
|
||||
@@ -72,9 +73,10 @@ If no specific provider instance is mapped to a task, the task automatically rou
|
||||
|
||||
To maintain backwards-compatibility and support headless runs, live evaluation suites, and automated unit tests without requiring database pre-configuration, the config manager supports **self-bootstrapping**:
|
||||
|
||||
1. When the provider manager queries the active key instance, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY`.
|
||||
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Default (Env)`) in `settings.db`.
|
||||
3. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` to keep execution fluent and error-free.
|
||||
1. When any database connection is initialized via the provider manager, if `data/settings.db` contains **0 registered keys**, it checks the process environment for `GOOGLE_API_KEY` and `OPENROUTER_API_KEY`.
|
||||
2. If `process.env.GOOGLE_API_KEY` is present, it automatically creates, saves, and activates a default provider instance (`Gemini (Env)`) in `settings.db`.
|
||||
3. If `process.env.OPENROUTER_API_KEY` is present, it automatically creates and saves a default provider instance (`OpenRouter (Env)`) in `settings.db`.
|
||||
4. If database write locks occur (e.g., during high-concurrency Vitest test suites), the system seamlessly returns a temporary in-memory `LLMProviderInstance` (`Gemini (Env Fallback)` or `OpenRouter (Env Fallback)`) to keep execution fluent and error-free.
|
||||
|
||||
---
|
||||
|
||||
|
||||
14
web/docs/wrangler.jsonc
Normal file
14
web/docs/wrangler.jsonc
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "omnia-docs",
|
||||
"compatibility_date": "2026-07-09",
|
||||
"assets": {
|
||||
"directory": "./dist"
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "omnia.adityagupta.dev/docs*",
|
||||
"zone_name": "adityagupta.dev"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user