Skip to content

Developer Notes

Critical rules and gotchas that every N.E.K.O. contributor must know. These are distilled from hard-won project experience.

Core rules

MUST follow

These rules are enforced across the entire codebase.

1. Always use uv to run anything

All Python commands must go through uv:

bash
# ✅ Correct
uv run python -m app.main_server
uv run pytest tests/

# ❌ Wrong
python -m app.main_server
pytest tests/

2. i18n is mandatory for all user-facing text

The project supports 8 languages (en, zh-CN, zh-TW, ja, ko, ru, es, pt). All user-visible strings must go through the i18n system.

  • HTML: Use data-i18n attributes
  • JS: Use window.t('key') with Chinese fallback
  • Locale files live in static/locales/

See Internationalization for the full guide.

3. Privacy-sensitive logs: print() only

Any log that could contain raw user conversation data must use print(), never logger. This ensures sensitive data stays out of persistent log files.

python
# ✅ User conversation data
print(f"User said: {user_message}")

# ✅ System events use logger
logger.info("Session started for character: %s", lanlan_name)

# ❌ Never log user conversations with logger
logger.info(f"User said: {user_message}")  # BAD!

4. Preserve system prompt watermark when translating

When translating system prompts (for any reason), always preserve the marker ======以上为. This is an internal watermark used for prompt boundary detection.

5. Steam achievements are irreversible

Once a Steam achievement is unlocked, it cannot be revoked via code. Always test achievement logic thoroughly with console commands before deploying:

javascript
// Test in browser console
await window.unlockAchievement('ACH_NAME');
window.getAchievementStats();

6. Privacy mode gates SCREEN/APP visibility only — never user understanding

Privacy mode (ais_privacy_mode_enabled() / is_privacy_mode_active()) has exactly one job: deciding whether the AI may see the user's screen and program/app state. It must NOT be used to gate any feature that helps the AI understand the user.

N.E.K.O. is an AI companion. Reading the user's emotional/conversational state from what they type or say is core to the product and is always allowed, privacy mode or not. The user's messages are not "screen data".

python
# ❌ WRONG — do NOT gate companion/understanding logic on privacy mode
if await ais_privacy_mode_enabled():
    return  # skip emotion scoring / focus / memory reflection ...

# ✅ RIGHT — only gate things that literally read the screen / app state
if not await ais_privacy_mode_enabled():
    screenshot = capture_screen()      # vision input
    app_state = read_foreground_app()  # activity tracker enrichment

Rule of thumb: if a feature's input is the conversation (text/voice the user directed at the AI), it is privacy-independent. If its input is the screen or OS/app state the user did not explicitly send, that single source is what privacy mode governs.

Concretely:

  • ✅ Focus (凝神) scores the user's message (vulnerability keywords, reply cadence) — privacy-independent by construction; it must not fetch a screen snapshot or check privacy on the inline path.
  • ✅ Memory reflection / persona / emotional-episode synthesis run on the conversation — privacy-independent.
  • ✅ The activity-tracker snapshot (screen genre, foreground app, open windows) is correctly nulled under privacy — that is the legitimate boundary. Features that consume it degrade gracefully when it is None, but they must not add their own extra privacy check on top.

If you find code that disables an understanding feature based on privacy mode, it is a bug — remove the gate.

7. Minigame prompts keep two locale schemes on purpose

config/prompts deliberately runs two different locale normalizers for minigame prompts, and they must not be unified:

  • Badminton quick-lines use normalize_badminton_prompt_locale (in prompts_badminton.py), a full-locale scheme that keeps zh-CN and zh-TW apart. The tables BADMINTON_QUICK_LINES_PROMPTS / BADMINTON_QUICK_LINES_FALLBACKS are keyed by full locale so the Traditional-Chinese fallback lines survive.
  • Everything else (soccer, plus every system / pregame prompt, including BADMINTON_SYSTEM_PROMPTS) uses _normalize_prompt_lang (in prompts_minigame_common.py), a short-locale scheme that collapses every Chinese variant down to zh.

Collapsing the badminton quick-lines onto the short scheme would fold zh-TW back into zh-CN and regress the Traditional-Chinese fallback fix from PR #2000. Each normalizer owns its own tables; do not "tidy up" by merging them.

Frontend gotchas

i18n kills HTML icons

When i18next updates element text via textContent, it destroys any <img> or <span> tags inside the element. If your translation string contains HTML, the i18n system detects this and uses innerHTML instead. If you're adding icons to translatable elements, include the HTML in the locale JSON:

json
{
  "button.save": "<img src='icon.svg'> Save"
}

overflow: hidden breaks <select> dropdowns

The capsule UI system uses large border-radius, which often leads developers to add overflow: hidden to containers. This clips native <select> dropdowns. Fix:

css
/* Any container with a <select> inside */
.field-row-with-select {
  overflow: visible !important;
}

Button interaction formula

All buttons must follow this interaction pattern for consistent feel:

css
.button:hover {
  transform: translateY(-1px);
  /* enhanced shadow */
}
.button:active {
  transform: translateY(1px) scale(0.98);
}

Vanilla JS race conditions (DOM lazy loading)

Since N.E.K.O. uses vanilla JavaScript without a reactive framework, DOM elements may not exist when your code runs — especially popups and HUD components that are created lazily on first click.

Never use fixed setTimeout for DOM binding

A hardcoded setTimeout(..., 100) will miss elements that haven't been created yet. Use self-terminating recursive polling instead:

javascript
const bindEvents = () => {
    const getEl = (ids) => {
        for (let id of ids) {
            const el = document.getElementById(id);
            if (el) return el;
        }
        return null;
    };

    const targetEl = getEl(['live2d-agent-keyboard', 'vrm-agent-keyboard']);

    if (!targetEl) {
        setTimeout(bindEvents, 500); // Retry until DOM exists
        return;
    }

    // Found — bind and stop polling
    targetEl.addEventListener('change', myLogic);
    myLogic(); // Trigger first check
};

setTimeout(bindEvents, 100); // Start polling

Optimistic UI conflicts: When a toggle button is clicked, the UI optimistically flips to "on" while a backend request is in flight. If another component (e.g., a polling loop) reads the DOM during this window, it may see stale state. Guard against this by checking whether the element is in a loading/disabled state before trusting its value.

UI design system: Capsule UI + Neko Blue

The project has a strict visual system:

TokenValueUsage
--color-n-main#40C5F1Brand blue: titles, primary buttons, active states
--color-n-deep#22b3ffStroke/deep blue: text outlines, focus glow
--color-n-light#e3f4ffLight background blue
--color-n-border#b3e5fcBorder blue: capsule borders, dividers
--radius-capsule50pxAll interactive elements
--radius-card20pxCards and containers

Fonts:

  • Latin: 'Comic Neue', 'Segoe UI', Arial
  • CJK: 'Source Han Sans CN', 'Noto Sans SC'
  • Monospace (API keys, IDs): 'Courier New', monospace

See the full design system in .agent/skills/ui-system-refactor/references/design-system.md.

Backend gotchas

Gemini API response format

Gemini may wrap JSON responses in markdown code blocks:

```json
{"emotion": "happy"}
```

Always strip markdown wrapping before parsing:

python
if result_text.startswith("```"):
    lines = result_text.split("\n")
    if lines[0].startswith("```"):
        lines = lines[1:]
    if lines and lines[-1].strip() == "```":
        lines = lines[:-1]
    result_text = "\n".join(lines).strip()

Gemini extra_body requires double nesting

When controlling Gemini's thinking mode via OpenAI-compatible API, the extra_body must be double-nested:

python
# ✅ Correct: double nesting
extra_body = {
    "extra_body": {
        "google": {
            "thinking_config": {
                "thinking_budget": 0  # Disable thinking for 2.5
            }
        }
    }
}

# ❌ Wrong: single nesting (causes "Unknown name 'google'" error)
extra_body = {
    "google": {
        "thinking_config": {"thinking_budget": 0}
    }
}

Thinking mode varies by provider

Each LLM provider has a different format for disabling extended reasoning:

ProviderFormat
Qwen, Step, DeepSeek{"enable_thinking": false}
GLM{"thinking": {"type": "disabled"}}
Gemini 2.x{"thinking_config": {"thinking_budget": 0}}
Gemini 3.x{"thinking_config": {"thinking_level": "low"}}

The config/__init__.py module handles this mapping automatically — check MODELS_EXTRA_BODY_MAP.

VRM model gotchas

SpringBone physics explosion

VRM physics uses vrm.update(delta) where delta must be in seconds, not milliseconds. If hair/clothing flies upward on load:

javascript
let delta = clock.getDelta();
delta = Math.min(delta, 0.05); // Clamp to prevent explosion on tab switch
vrm.update(delta);

Oversized colliders (affects ~100% of VRM models)

VRM models exported from VRoid Studio/UniVRM have a known bug where collider radii are ~2x too large (UniVRM #673). This causes hair to appear stuck horizontally.

Fix: Reduce all collider radii by 50% after loading:

javascript
const COLLIDER_REDUCTION = 0.5;
springBoneManager.colliders.forEach(collider => {
    if (collider.shape?.radius > 0) {
        collider._originalRadius = collider.shape.radius;
        collider.shape.radius *= COLLIDER_REDUCTION;
    }
});

MToon outline thickness

When VRM models are scaled, MToon outlines become disproportionately thick. Switch to screen-space outlines:

javascript
material.outlineWidthMode = 'screenCoordinates';
material.outlineWidthFactor = 0.005; // Thin, consistent outline
material.needsUpdate = true;

3D camera: pixel-to-world mapping

When implementing drag/zoom for VRM models, never use a fixed pan speed. Calculate the pixel-to-world mapping dynamically based on camera distance:

javascript
const worldHeight = 2 * Math.tan(fov / 2) * cameraDistance;
const pixelToWorld = worldHeight / screenHeight;
// Mouse delta * pixelToWorld = world space movement

Testing

Test structure

tests/
├── unit/          # OmniOffline/Realtime clients, provider connectivity
├── frontend/      # Playwright tests for each Web UI page
├── e2e/           # Full user journey (8 stages, needs --run-e2e flag)
└── utils/         # LLM-based response quality evaluator

Running tests

bash
# All tests (excluding e2e)
uv run pytest tests/ -s

# Unit tests only
uv run pytest tests/unit -s

# Frontend tests (requires Playwright browsers)
uv run playwright install
uv run pytest tests/frontend -s

# E2E tests (requires explicit flag)
uv run pytest tests/e2e --run-e2e -s

API keys for tests

Copy tests/api_keys.json.template to tests/api_keys.json and fill in your keys. This file is gitignored.

Issue templates

When filing bugs or requesting features, use the GitHub issue templates:

  • Bug report: Include reproduction steps, expected vs actual behavior, and environment info
  • Feature request: Describe the feature, its use case, and any relevant context

Released under the MIT License.