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:
# ✅ 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-i18nattributes - 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.
# ✅ 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:
// 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".
# ❌ 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 enrichmentRule 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(inprompts_badminton.py), a full-locale scheme that keepszh-CNandzh-TWapart. The tablesBADMINTON_QUICK_LINES_PROMPTS/BADMINTON_QUICK_LINES_FALLBACKSare 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(inprompts_minigame_common.py), a short-locale scheme that collapses every Chinese variant down tozh.
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:
{
"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:
/* 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:
.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:
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 pollingOptimistic 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:
| Token | Value | Usage |
|---|---|---|
--color-n-main | #40C5F1 | Brand blue: titles, primary buttons, active states |
--color-n-deep | #22b3ff | Stroke/deep blue: text outlines, focus glow |
--color-n-light | #e3f4ff | Light background blue |
--color-n-border | #b3e5fc | Border blue: capsule borders, dividers |
--radius-capsule | 50px | All interactive elements |
--radius-card | 20px | Cards 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:
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:
# ✅ 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:
| Provider | Format |
|---|---|
| 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:
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:
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:
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:
const worldHeight = 2 * Math.tan(fov / 2) * cameraDistance;
const pixelToWorld = worldHeight / screenHeight;
// Mouse delta * pixelToWorld = world space movementTesting
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 evaluatorRunning tests
# 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 -sAPI 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
