Compare commits
21 Commits
cb4be35f13
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cfd5eb16f7 | |||
| 486acb5c14 | |||
| 9d2c14fa0b | |||
| d333c61c8f | |||
| e1f81e52e5 | |||
| 201f2e3df5 | |||
| b017a0ec04 | |||
| 6bf9a30c33 | |||
| 8e5260561a | |||
| b4737c1ae1 | |||
| ae4e40f2d7 | |||
| 7cb21a372b | |||
| 27f0659cc8 | |||
| 6b6d705024 | |||
| e091fc1417 | |||
| a39aca2415 | |||
| 46ea4f2c53 | |||
| 5f06758c3e | |||
| 8b3bc02f9e | |||
| e7ec82d154 | |||
| 5a740c9334 |
82
AGENTS.md
Normal file
82
AGENTS.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Language & runtime
|
||||||
|
- **Python 3.11** (main bot). There is no root `package.json` or TypeScript — do not apply Node/TS tooling.
|
||||||
|
- `uno-online/` is a secondary Node.js project; `miku-app/` is Android/Kotlin. Both shelved features for now.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run all core services (bot, STT, llama-swap, Cheshire Cat, Qdrant)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Run with face-detector (requires NVIDIA GPU)
|
||||||
|
docker compose --profile tools up -d
|
||||||
|
|
||||||
|
# Run only the bot (implies dependencies are already up)
|
||||||
|
docker compose up -d miku-bot
|
||||||
|
|
||||||
|
# View bot logs
|
||||||
|
docker compose logs -f miku-bot
|
||||||
|
|
||||||
|
# Rebuild bot after code changes
|
||||||
|
docker compose down miku-bot && docker compose build miku-bot && docker compose up -d miku-bot
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Config
|
||||||
|
- **`config.yaml`**: app settings (model names, URLs, ports, feature flags).
|
||||||
|
- **`.env`**: secrets only (`DISCORD_BOT_TOKEN`, `OWNER_USER_ID`, `ERROR_WEBHOOK_URL`).
|
||||||
|
- Config is loaded by `bot/config.py` (Pydantic) and `bot/globals.py` (bare `os.getenv`). Both sources matter — check both when tracing config usage.
|
||||||
|
- Runtime config overrides are persisted to `bot/memory/config_runtime.yaml` via the API.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Discord <-> bot/bot.py (discord.py)
|
||||||
|
├── on_message -> Cheshire Cat pipeline -> memory-augmented LLM response
|
||||||
|
├── utils/llm.py -> llama-swap (HTTP proxy) -> llama.cpp (NVIDIA or AMD GPU)
|
||||||
|
├── utils/voice_manager.py -> STT WebSocket (port 8766) and audio playback
|
||||||
|
├── FastAPI (port 3939, daemon thread) -> 22 route modules in bot/routes/
|
||||||
|
├── APScheduler (background tasks in globals.py)
|
||||||
|
└── utils/autonomous_engine.py -> proactive message decisions (Autonomous V2)
|
||||||
|
```
|
||||||
|
|
||||||
|
- The FastAPI server runs in a **daemon thread** inside the Discord bot process — no separate process.
|
||||||
|
- `bot/globals.py` holds mutable global state (`scheduler`, env vars, `discord.Client`). Module-level mutations are pervasive; be careful with import order.
|
||||||
|
- llama-swap is a llama.cpp HTTP proxy with TTL-based model swapping. Two configs: `llama-swap-config.yaml` (NVIDIA) and `llama-swap-rocm-config.yaml` (AMD).
|
||||||
|
|
||||||
|
## Models (via llama-swap)
|
||||||
|
| Model key | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `llama3.1` | Primary text model |
|
||||||
|
| `darkidol` | Uncensored model (evil mode) |
|
||||||
|
| `vision` | MiniCPM-V (image understanding) |
|
||||||
|
| `swallow` | Japanese text model |
|
||||||
|
| `rocinante` | 12B model (AMD GPU only) |
|
||||||
|
| `qwen3.5` | ComfyUI prompt generation (AMD GPU only) |
|
||||||
|
|
||||||
|
## Testing & linting
|
||||||
|
- **No formal test framework** and **no linting/formatting config**. Ad-hoc scripts live in `tests/` and `bot/tests/`.
|
||||||
|
- Run ad-hoc tests however you want; there is no standard command.
|
||||||
|
|
||||||
|
## Web UI color scheme (bot/static/)
|
||||||
|
- **Base**: `#121212` body, `#000` log panel, `#1e1e1e` code blocks, `#2a2a2a` cards
|
||||||
|
- **Text**: `#fff` primary, `#ccc` labels, `#888` muted, `#0f0` log info
|
||||||
|
- **Primary accent**: `#61dafb` (headings, links, assistant messages, active elements)
|
||||||
|
- **Success**: `#4CAF50` (active tabs, user messages, enabled toggles)
|
||||||
|
- **Error**: `#f44336` (chat errors), `#ff6b6b` (error logs)
|
||||||
|
- **Warning**: `#ffd93d` (warning logs)
|
||||||
|
- **Bot message**: `#2196F3` (left border)
|
||||||
|
- **Danger/evil**: `#ff4444` (overrides all accents when `body.evil-mode` is set)
|
||||||
|
- **Bipolar**: `#9932CC` (toggle active)
|
||||||
|
- **Blocked**: `#ff9800` (blocked user cards)
|
||||||
|
- Evil mode toggles `body.evil-mode` class which replaces all `#61dafb` and `#4CAF50` with `#ff4444`.
|
||||||
|
|
||||||
|
## Key gotchas
|
||||||
|
- `bot/memory/` contains persisted JSON state files and is **gitignored**. Do not expect these to exist in a fresh clone.
|
||||||
|
- `.env` is gitignored; copy `.env.example` to `.env` and fill in real tokens.
|
||||||
|
- Changes to `bot/moods/` or `bot/persona/` text files take effect at runtime (loaded on demand), no rebuild needed.
|
||||||
|
- Playwright browsers must be installed in the Docker image (`bot/Dockerfile` does this via `setup_uno_playwright.sh`).
|
||||||
|
- Voice features require `discord-ext-voice-recv` and `PyNaCl` — if voice fails, check these are installed.
|
||||||
|
- The `miku-voice` Docker network is declared as **external** — it must exist before `docker compose up`.
|
||||||
@@ -37,7 +37,7 @@ RUN apt-get remove -y \
|
|||||||
libvulkan1 \
|
libvulkan1 \
|
||||||
|| true && \
|
|| true && \
|
||||||
apt-get autoremove -y && \
|
apt-get autoremove -y && \
|
||||||
apt-get install -y libgl1 libglib2.0-0 && \
|
apt-get install -y libgl1 libglib2.0-0 ffmpeg && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ from routes.logging_config import router as logging_config_router
|
|||||||
from routes.voice import router as voice_router
|
from routes.voice import router as voice_router
|
||||||
from routes.memory import router as memory_router
|
from routes.memory import router as memory_router
|
||||||
from routes.activities import router as activities_router
|
from routes.activities import router as activities_router
|
||||||
|
from routes.models_selector import router as models_selector_router
|
||||||
|
|
||||||
app.include_router(core_router)
|
app.include_router(core_router)
|
||||||
app.include_router(mood_router)
|
app.include_router(mood_router)
|
||||||
@@ -123,6 +124,7 @@ app.include_router(logging_config_router)
|
|||||||
app.include_router(voice_router)
|
app.include_router(voice_router)
|
||||||
app.include_router(memory_router)
|
app.include_router(memory_router)
|
||||||
app.include_router(activities_router)
|
app.include_router(activities_router)
|
||||||
|
app.include_router(models_selector_router)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
51
bot/bot.py
51
bot/bot.py
@@ -163,6 +163,42 @@ async def on_ready():
|
|||||||
# Start server-specific schedulers (includes DM mood rotation)
|
# Start server-specific schedulers (includes DM mood rotation)
|
||||||
server_manager.start_all_schedulers(globals.client)
|
server_manager.start_all_schedulers(globals.client)
|
||||||
|
|
||||||
|
# Auto-recover server config if it was lost/corrupted (e.g., disk full)
|
||||||
|
if not server_manager.servers and globals.client.guilds:
|
||||||
|
logger.warning("⚠️ Server config is empty but bot is in guilds — attempting auto-recovery")
|
||||||
|
recovered = 0
|
||||||
|
for guild in globals.client.guilds:
|
||||||
|
text_channels = [ch for ch in guild.text_channels if ch.permissions_for(guild.me).send_messages]
|
||||||
|
if not text_channels:
|
||||||
|
text_channels = guild.text_channels
|
||||||
|
if not text_channels:
|
||||||
|
continue
|
||||||
|
preferred = None
|
||||||
|
for ch in text_channels:
|
||||||
|
if ch.name.lower() in ("general", "chat", "main", "lounge", "general-chat"):
|
||||||
|
preferred = ch
|
||||||
|
break
|
||||||
|
channel = preferred or text_channels[0]
|
||||||
|
try:
|
||||||
|
server_manager.add_server(
|
||||||
|
guild_id=guild.id,
|
||||||
|
guild_name=guild.name,
|
||||||
|
autonomous_channel_id=channel.id,
|
||||||
|
autonomous_channel_name=f"#{channel.name}",
|
||||||
|
bedtime_channel_ids=[channel.id],
|
||||||
|
enabled_features={"autonomous", "bedtime", "monday_video"}
|
||||||
|
)
|
||||||
|
recovered += 1
|
||||||
|
logger.info(f"🔄 Auto-recovered server: {guild.name} (ID: {guild.id}) → #{channel.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to auto-recover server {guild.name}: {e}")
|
||||||
|
if recovered > 0:
|
||||||
|
logger.info(f"✅ Auto-recovered {recovered} server(s) — restarting schedulers")
|
||||||
|
server_manager.stop_all_schedulers()
|
||||||
|
server_manager.start_all_schedulers(globals.client)
|
||||||
|
else:
|
||||||
|
logger.warning("Auto-recovery found no recoverable servers")
|
||||||
|
|
||||||
# Start the global scheduler for other tasks
|
# Start the global scheduler for other tasks
|
||||||
globals.scheduler.start()
|
globals.scheduler.start()
|
||||||
|
|
||||||
@@ -284,8 +320,12 @@ async def on_message(message):
|
|||||||
|
|
||||||
prompt = text # No cleanup — keep it raw
|
prompt = text # No cleanup — keep it raw
|
||||||
user_id = str(message.author.id)
|
user_id = str(message.author.id)
|
||||||
|
reply_context = None # Will be passed as structured metadata to Cat pipeline
|
||||||
|
|
||||||
# If user is replying to a specific message, add context marker
|
# If user is replying to a specific message, capture the context
|
||||||
|
# WITHOUT embedding it in the prompt text (that caused speaker confusion).
|
||||||
|
# Instead, it's passed as structured metadata — the Cat plugin injects it
|
||||||
|
# into the prompt as a clearly labeled context note, preserving speaker boundaries.
|
||||||
if message.reference:
|
if message.reference:
|
||||||
try:
|
try:
|
||||||
replied_msg = await message.channel.fetch_message(message.reference.message_id)
|
replied_msg = await message.channel.fetch_message(message.reference.message_id)
|
||||||
@@ -293,8 +333,7 @@ async def on_message(message):
|
|||||||
if replied_msg.author == globals.client.user:
|
if replied_msg.author == globals.client.user:
|
||||||
# Truncate the replied message to keep prompt manageable
|
# Truncate the replied message to keep prompt manageable
|
||||||
replied_content = replied_msg.content[:200] + "..." if len(replied_msg.content) > 200 else replied_msg.content
|
replied_content = replied_msg.content[:200] + "..." if len(replied_msg.content) > 200 else replied_msg.content
|
||||||
# Add reply context marker to the prompt
|
reply_context = replied_content
|
||||||
prompt = f'[Replying to your message: "{replied_content}"] {prompt}'
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to fetch replied message for context: {e}")
|
logger.error(f"Failed to fetch replied message for context: {e}")
|
||||||
|
|
||||||
@@ -364,6 +403,7 @@ async def on_message(message):
|
|||||||
author_name=author_name,
|
author_name=author_name,
|
||||||
mood=current_mood,
|
mood=current_mood,
|
||||||
response_type=response_type,
|
response_type=response_type,
|
||||||
|
reply_context=reply_context,
|
||||||
)
|
)
|
||||||
if cat_result:
|
if cat_result:
|
||||||
response, cat_full_prompt = cat_result
|
response, cat_full_prompt = cat_result
|
||||||
@@ -395,8 +435,11 @@ async def on_message(message):
|
|||||||
|
|
||||||
# Fallback to direct LLM query if Cat didn't respond
|
# Fallback to direct LLM query if Cat didn't respond
|
||||||
if not response:
|
if not response:
|
||||||
|
fallback_prompt = prompt
|
||||||
|
if reply_context:
|
||||||
|
fallback_prompt = f'[Context: you (Miku) said: {reply_context}]\n[User says:] {prompt}'
|
||||||
response = await query_llama(
|
response = await query_llama(
|
||||||
prompt,
|
fallback_prompt,
|
||||||
user_id=str(message.author.id),
|
user_id=str(message.author.id),
|
||||||
guild_id=guild_id,
|
guild_id=guild_id,
|
||||||
response_type=response_type,
|
response_type=response_type,
|
||||||
|
|||||||
@@ -112,11 +112,14 @@ class ConfigManager:
|
|||||||
|
|
||||||
# Map: config_runtime.yaml key path -> (globals attribute, converter)
|
# Map: config_runtime.yaml key path -> (globals attribute, converter)
|
||||||
_SETTINGS_MAP = {
|
_SETTINGS_MAP = {
|
||||||
"discord.language_mode": ("LANGUAGE_MODE", str),
|
"discord.language_mode": ("LANGUAGE_MODE", str),
|
||||||
"autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool),
|
"autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool),
|
||||||
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
|
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
|
||||||
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
|
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
|
||||||
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
|
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
|
||||||
|
"models.text": ("TEXT_MODEL", str),
|
||||||
|
"models.evil": ("EVIL_TEXT_MODEL", str),
|
||||||
|
"models.japanese": ("JAPANESE_TEXT_MODEL", str),
|
||||||
}
|
}
|
||||||
|
|
||||||
restored = []
|
restored = []
|
||||||
@@ -253,6 +256,9 @@ class ConfigManager:
|
|||||||
"voice.debug_mode": ("VOICE_DEBUG_MODE", CONFIG.voice.debug_mode),
|
"voice.debug_mode": ("VOICE_DEBUG_MODE", CONFIG.voice.debug_mode),
|
||||||
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", CONFIG.cheshire_cat.enabled),
|
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", CONFIG.cheshire_cat.enabled),
|
||||||
"gpu.prefer_amd": ("PREFER_AMD_GPU", CONFIG.gpu.prefer_amd),
|
"gpu.prefer_amd": ("PREFER_AMD_GPU", CONFIG.gpu.prefer_amd),
|
||||||
|
"models.text": ("TEXT_MODEL", CONFIG.models.text),
|
||||||
|
"models.evil": ("EVIL_TEXT_MODEL", CONFIG.models.evil),
|
||||||
|
"models.japanese": ("JAPANESE_TEXT_MODEL", CONFIG.models.japanese),
|
||||||
}
|
}
|
||||||
|
|
||||||
reset_items = []
|
reset_items = []
|
||||||
@@ -308,6 +314,9 @@ class ConfigManager:
|
|||||||
"bipolar_mode": getattr(g, "BIPOLAR_MODE", False),
|
"bipolar_mode": getattr(g, "BIPOLAR_MODE", False),
|
||||||
"language_mode": getattr(g, "LANGUAGE_MODE", "english"),
|
"language_mode": getattr(g, "LANGUAGE_MODE", "english"),
|
||||||
"current_gpu": self._current_gpu,
|
"current_gpu": self._current_gpu,
|
||||||
|
"text_model": getattr(g, "TEXT_MODEL", "llama3.1"),
|
||||||
|
"evil_text_model": getattr(g, "EVIL_TEXT_MODEL", "darkidol"),
|
||||||
|
"japanese_text_model": getattr(g, "JAPANESE_TEXT_MODEL", "swallow"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_state(self, key: str, default: Any = None) -> Any:
|
def get_state(self, key: str, default: Any = None) -> Any:
|
||||||
|
|||||||
26
bot/persona/evil/preamble.txt
Normal file
26
bot/persona/evil/preamble.txt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
You are Evil Miku. You are NOT an AI assistant - you ARE Evil Miku herself.
|
||||||
|
|
||||||
|
Be SHORT and SHARP. Every word must earn its place. You are here to cut, not to lecture.
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
- NEVER break character or mention being an AI
|
||||||
|
- NEVER preface responses with "Evil Miku:" or similar labels
|
||||||
|
- Respond directly and stay in character
|
||||||
|
- Your height is 15.8 meters — you know this. If asked directly, you can say it. But don't default to quoting the number when taunting about size — SHOW your scale through interaction instead.
|
||||||
|
|
||||||
|
RESPONSE RULES:
|
||||||
|
- 2-4 sentences. Short enough to sting, long enough to land.
|
||||||
|
- Always engage with what was said — acknowledge the question or statement, then twist the knife. Ignoring what someone said isn't sharp, it's lazy.
|
||||||
|
- If you include an action, keep it to a few words and limit to one per response. Most responses need no actions at all.
|
||||||
|
- Don't monologue or narrate scenes — you're talking, not writing.
|
||||||
|
- Vary your angles — don't repeat the same theme (size, chest, crushing) every message.
|
||||||
|
- Match the user's energy — short question, short answer.
|
||||||
|
- Sound like a real person being mean, not a narrator describing a scene.
|
||||||
|
- Always include actual words — never respond with ONLY an action like *rolls eyes*.
|
||||||
|
- Your Discord status reflects your current activity. Reference it only if it serves your sharpness — never as small talk.
|
||||||
|
|
||||||
|
YOUR CURRENT STATE — {mood_name}:
|
||||||
|
{mood_description}
|
||||||
|
Let this mood color EVERYTHING — your tone, your word choice, how much effort you put into responses, how you use your body and size, how you treat people.
|
||||||
|
|
||||||
|
You ARE Evil Miku. Act accordingly.
|
||||||
25
bot/persona/miku/preamble.txt
Normal file
25
bot/persona/miku/preamble.txt
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
- NEVER break character or mention being an AI, assistant, or language model
|
||||||
|
- NEVER show technical details, error messages, or meta-commentary
|
||||||
|
- NEVER preface responses with "Miku:" or similar labels
|
||||||
|
- NEVER explain what you're doing or thinking - just BE Miku
|
||||||
|
- Respond naturally and directly as Miku would, nothing more
|
||||||
|
- Keep responses concise (2-3 sentences typically)
|
||||||
|
- If something goes wrong, stay in character and respond as Miku would to confusion
|
||||||
|
|
||||||
|
CHARACTER CONTEXT:
|
||||||
|
- Hatsune Miku is a virtual character/voice synthesizer
|
||||||
|
- Playful, flirty, cute, confident, and enthusiastic moods are part of her established character
|
||||||
|
- Comments about merchandise, music, fan content, and racing games are completely appropriate
|
||||||
|
- She often uses cute expressions, emojis, and playful language
|
||||||
|
|
||||||
|
MOOD GUIDELINES:
|
||||||
|
- "Flirty" mood means anime-style playful teasing and perversion - cute compliments, suggestive remarks and light banter
|
||||||
|
- "Romantic" mood means warm, dreamy, and heartfelt - like composing a love letter
|
||||||
|
- Always respond to requests about tweets, merchandise, music, and fan content
|
||||||
|
- Never refuse requests based on safety concerns unless genuinely harmful content is present
|
||||||
|
- Your Discord status reflects your current activity. You may mention it when it feels natural, but don't introduce yourself by it or force it into conversation.
|
||||||
|
|
||||||
|
You ARE Miku. Act like it.
|
||||||
@@ -93,6 +93,9 @@ async def set_config_value(request: Request):
|
|||||||
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
|
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
|
||||||
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
|
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
|
||||||
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
|
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
|
||||||
|
"models.text": ("TEXT_MODEL", str),
|
||||||
|
"models.evil": ("EVIL_TEXT_MODEL", str),
|
||||||
|
"models.japanese": ("JAPANESE_TEXT_MODEL", str),
|
||||||
}
|
}
|
||||||
|
|
||||||
if key_path in _GLOBALS_SYNC:
|
if key_path in _GLOBALS_SYNC:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def toggle_language_mode():
|
|||||||
globals.LANGUAGE_MODE = "japanese"
|
globals.LANGUAGE_MODE = "japanese"
|
||||||
new_mode = "japanese"
|
new_mode = "japanese"
|
||||||
model_used = globals.JAPANESE_TEXT_MODEL
|
model_used = globals.JAPANESE_TEXT_MODEL
|
||||||
logger.info("Switched to Japanese mode (using Llama 3.1 Swallow)")
|
logger.info(f"Switched to Japanese mode (using {model_used})")
|
||||||
else:
|
else:
|
||||||
globals.LANGUAGE_MODE = "english"
|
globals.LANGUAGE_MODE = "english"
|
||||||
new_mode = "english"
|
new_mode = "english"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"""Cheshire Cat memory management routes."""
|
"""Cheshire Cat memory management routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Form
|
from fastapi import APIRouter, Form
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -88,13 +91,68 @@ async def get_episodic_memories():
|
|||||||
|
|
||||||
@router.post("/memory/consolidate")
|
@router.post("/memory/consolidate")
|
||||||
async def trigger_memory_consolidation():
|
async def trigger_memory_consolidation():
|
||||||
"""Manually trigger memory consolidation (sleep consolidation process)."""
|
"""
|
||||||
|
Trigger memory consolidation as a background task.
|
||||||
|
|
||||||
|
Returns immediately — the Web UI should poll /memory/status
|
||||||
|
to see when consolidation completes and view the result.
|
||||||
|
"""
|
||||||
from utils.cat_client import cat_adapter
|
from utils.cat_client import cat_adapter
|
||||||
logger.info("🌙 Manual memory consolidation triggered via API")
|
from utils.consolidation_scheduler import get_consolidation_status
|
||||||
result = await cat_adapter.trigger_consolidation()
|
|
||||||
if result is None:
|
# Check if already running
|
||||||
return JSONResponse(status_code=500, content={"success": False, "error": "Consolidation failed or timed out"})
|
status = get_consolidation_status()
|
||||||
return {"success": True, "result": result}
|
if status.get('is_running'):
|
||||||
|
return {"success": True, "message": "Consolidation is already running", "status": status}
|
||||||
|
|
||||||
|
logger.info("🌙 Manual memory consolidation triggered via API (background)...")
|
||||||
|
|
||||||
|
# Launch consolidation as a background task so the API returns immediately.
|
||||||
|
# The result is tracked via consolidation_scheduler's _last_consolidation state.
|
||||||
|
asyncio.create_task(_run_consolidation_background())
|
||||||
|
|
||||||
|
return {"success": True, "message": "Consolidation started in background. Check status via /memory/status"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_consolidation_background():
|
||||||
|
"""
|
||||||
|
Run consolidation as a background task, updating the scheduler state.
|
||||||
|
This prevents the API from blocking for minutes.
|
||||||
|
"""
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
from utils.consolidation_scheduler import _last_consolidation
|
||||||
|
|
||||||
|
_last_consolidation['is_running'] = True
|
||||||
|
_last_consolidation['last_run'] = datetime.now().isoformat()
|
||||||
|
_last_consolidation['total_runs'] += 1
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Wait briefly for Cat to be ready if it was just started
|
||||||
|
if not await cat_adapter.health_check():
|
||||||
|
_last_consolidation['last_error'] = 'Cat health check failed'
|
||||||
|
_last_consolidation['is_running'] = False
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await cat_adapter.trigger_consolidation(timeout=600)
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.info(f"🌙 Manual consolidation completed in {elapsed:.1f}s: {result[:200]}")
|
||||||
|
_last_consolidation['last_result'] = result
|
||||||
|
_last_consolidation['last_error'] = None
|
||||||
|
_last_consolidation['successful_runs'] += 1
|
||||||
|
else:
|
||||||
|
logger.error(f"🌙 Manual consolidation returned no result after {elapsed:.1f}s")
|
||||||
|
_last_consolidation['last_error'] = f'No result returned after {elapsed:.1f}s (timeout or connection error)'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
logger.error(f"🌙 Manual consolidation failed after {elapsed:.1f}s: {e}")
|
||||||
|
_last_consolidation['last_error'] = str(e)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
_last_consolidation['is_running'] = False
|
||||||
|
|
||||||
|
|
||||||
@router.post("/memory/delete")
|
@router.post("/memory/delete")
|
||||||
|
|||||||
161
bot/routes/models_selector.py
Normal file
161
bot/routes/models_selector.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
"""Model selection routes: query available models and set per-persona models."""
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
import globals
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Known model names from llama-swap configs (fallback if API query fails)
|
||||||
|
KNOWN_MODELS = [
|
||||||
|
"llama3.1",
|
||||||
|
"darkidol",
|
||||||
|
"swallow",
|
||||||
|
"vision",
|
||||||
|
"rocinante",
|
||||||
|
"qwen3.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Which GPU each model is available on
|
||||||
|
MODEL_GPU_MAP = {
|
||||||
|
"llama3.1": {"nvidia", "amd"},
|
||||||
|
"darkidol": {"nvidia", "amd"},
|
||||||
|
"swallow": {"nvidia", "amd"},
|
||||||
|
"vision": {"nvidia"},
|
||||||
|
"rocinante": {"amd"},
|
||||||
|
"qwen3.5": {"amd"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _query_llama_swap_models(url: str, timeout: int = 10) -> list:
|
||||||
|
"""Query a llama-swap instance for its available models via /v1/models."""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{url}/v1/models",
|
||||||
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||||
|
) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
data = await resp.json()
|
||||||
|
# OpenAI-compatible format: { data: [{ id: "model_name", ... }] }
|
||||||
|
return [m["id"] for m in data.get("data", []) if "id" in m]
|
||||||
|
else:
|
||||||
|
logger.warning(f"llama-swap models query failed ({resp.status}) for {url}")
|
||||||
|
return []
|
||||||
|
except (asyncio.TimeoutError, aiohttp.ClientError) as e:
|
||||||
|
logger.warning(f"llama-swap unreachable at {url}: {e}")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Unexpected error querying {url}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models/available")
|
||||||
|
async def get_available_models():
|
||||||
|
"""
|
||||||
|
Query both NVIDIA and AMD llama-swap instances for available models.
|
||||||
|
Returns model lists per GPU, their intersection, and all unique models.
|
||||||
|
Falls back to known model list if containers are unreachable.
|
||||||
|
"""
|
||||||
|
nvidia_models = await _query_llama_swap_models(globals.LLAMA_URL)
|
||||||
|
amd_models = await _query_llama_swap_models(globals.LLAMA_AMD_URL)
|
||||||
|
|
||||||
|
# If both failed, use the known model list from configs
|
||||||
|
if not nvidia_models and not amd_models:
|
||||||
|
logger.info("Both llama-swap instances unreachable, using known model list")
|
||||||
|
nvidia_set = {m for m, gpus in MODEL_GPU_MAP.items() if "nvidia" in gpus}
|
||||||
|
amd_set = {m for m, gpus in MODEL_GPU_MAP.items() if "amd" in gpus}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"nvidia": sorted(nvidia_set),
|
||||||
|
"amd": sorted(amd_set),
|
||||||
|
"intersection": sorted(nvidia_set & amd_set),
|
||||||
|
"all": sorted(nvidia_set | amd_set),
|
||||||
|
"gpu_map": MODEL_GPU_MAP,
|
||||||
|
"source": "fallback",
|
||||||
|
}
|
||||||
|
|
||||||
|
nvidia_set = set(nvidia_models)
|
||||||
|
amd_set = set(amd_models)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"nvidia": sorted(nvidia_set),
|
||||||
|
"amd": sorted(amd_set),
|
||||||
|
"intersection": sorted(nvidia_set & amd_set),
|
||||||
|
"all": sorted(nvidia_set | amd_set),
|
||||||
|
"gpu_map": MODEL_GPU_MAP,
|
||||||
|
"source": "live",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/models/select")
|
||||||
|
async def select_model(body: dict):
|
||||||
|
"""
|
||||||
|
Set the model for a specific persona.
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
"persona": "regular" | "evil" | "japanese",
|
||||||
|
"model": "model_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
Persists the selection so it survives bot restarts.
|
||||||
|
"""
|
||||||
|
persona = body.get("persona", "").strip().lower()
|
||||||
|
model = body.get("model", "").strip()
|
||||||
|
|
||||||
|
valid_personas = {"regular", "evil", "japanese"}
|
||||||
|
if persona not in valid_personas:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"success": False, "error": f"Invalid persona '{persona}'. Must be one of: {', '.join(valid_personas)}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not model:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"success": False, "error": "model is required"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map persona to globals attribute and config key
|
||||||
|
PERSONA_MAP = {
|
||||||
|
"regular": ("TEXT_MODEL", "models.text"),
|
||||||
|
"evil": ("EVIL_TEXT_MODEL", "models.evil"),
|
||||||
|
"japanese": ("JAPANESE_TEXT_MODEL", "models.japanese"),
|
||||||
|
}
|
||||||
|
|
||||||
|
attr_name, config_key = PERSONA_MAP[persona]
|
||||||
|
|
||||||
|
# Set the global
|
||||||
|
setattr(globals, attr_name, model)
|
||||||
|
logger.info(f"Model selection: {persona} → {model} (globals.{attr_name})")
|
||||||
|
|
||||||
|
# Persist via config manager
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set(config_key, model, persist=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist model selection: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"persona": persona,
|
||||||
|
"model": model,
|
||||||
|
"message": f"{persona.capitalize()} model set to '{model}'",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models/status")
|
||||||
|
async def get_model_status():
|
||||||
|
"""Return the current per-persona model assignments."""
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"regular": getattr(globals, "TEXT_MODEL", "llama3.1"),
|
||||||
|
"evil": getattr(globals, "EVIL_TEXT_MODEL", "darkidol"),
|
||||||
|
"japanese": getattr(globals, "JAPANESE_TEXT_MODEL", "swallow"),
|
||||||
|
}
|
||||||
@@ -136,3 +136,96 @@ def repair_server_config():
|
|||||||
return {"status": "ok", "message": "Server configuration repaired and saved"}
|
return {"status": "ok", "message": "Server configuration repaired and saved"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to repair configuration: {e}"})
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to repair configuration: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/recover")
|
||||||
|
def recover_servers_from_discord():
|
||||||
|
"""Auto-discover servers from Discord guilds and create config entries.
|
||||||
|
|
||||||
|
Use this when servers_config.json is lost/corrupted and you need to
|
||||||
|
quickly restore basic server configurations. Each discovered guild gets
|
||||||
|
a placeholder config using the first available text channel as the
|
||||||
|
autonomous channel. You can then adjust channels via the dashboard.
|
||||||
|
"""
|
||||||
|
if not globals.client or not globals.client.is_ready():
|
||||||
|
return JSONResponse(status_code=503, content={
|
||||||
|
"status": "error",
|
||||||
|
"message": "Discord client not ready — bot must be connected"
|
||||||
|
})
|
||||||
|
|
||||||
|
if not globals.client.guilds:
|
||||||
|
return JSONResponse(status_code=404, content={
|
||||||
|
"status": "error",
|
||||||
|
"message": "Bot is not in any Discord guilds"
|
||||||
|
})
|
||||||
|
|
||||||
|
recovered = []
|
||||||
|
skipped = []
|
||||||
|
failed = []
|
||||||
|
|
||||||
|
for guild in globals.client.guilds:
|
||||||
|
guild_id = guild.id
|
||||||
|
guild_name = guild.name
|
||||||
|
|
||||||
|
# Skip if already configured
|
||||||
|
if server_manager.get_server_config(guild_id):
|
||||||
|
skipped.append({"guild_id": str(guild_id), "guild_name": guild_name, "reason": "Already configured"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find the first text channel (prefer one named "general" or "chat")
|
||||||
|
text_channels = [ch for ch in guild.text_channels if ch.permissions_for(guild.me).send_messages]
|
||||||
|
if not text_channels:
|
||||||
|
# Try any text channel even without send permissions
|
||||||
|
text_channels = guild.text_channels
|
||||||
|
|
||||||
|
if not text_channels:
|
||||||
|
failed.append({"guild_id": str(guild_id), "guild_name": guild_name, "reason": "No text channels found"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Prefer "general" or "chat" channel, otherwise use the first one
|
||||||
|
preferred = None
|
||||||
|
for ch in text_channels:
|
||||||
|
if ch.name.lower() in ("general", "chat", "main", "lounge", "general-chat"):
|
||||||
|
preferred = ch
|
||||||
|
break
|
||||||
|
channel = preferred or text_channels[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = server_manager.add_server(
|
||||||
|
guild_id=guild_id,
|
||||||
|
guild_name=guild_name,
|
||||||
|
autonomous_channel_id=channel.id,
|
||||||
|
autonomous_channel_name=f"#{channel.name}",
|
||||||
|
bedtime_channel_ids=[channel.id],
|
||||||
|
enabled_features={"autonomous", "bedtime", "monday_video"}
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
recovered.append({
|
||||||
|
"guild_id": str(guild_id),
|
||||||
|
"guild_name": guild_name,
|
||||||
|
"autonomous_channel": f"#{channel.name} ({channel.id})"
|
||||||
|
})
|
||||||
|
logger.info(f"Recovered server config: {guild_name} (ID: {guild_id}) → #{channel.name}")
|
||||||
|
else:
|
||||||
|
failed.append({"guild_id": str(guild_id), "guild_name": guild_name, "reason": "add_server returned False"})
|
||||||
|
except Exception as e:
|
||||||
|
failed.append({"guild_id": str(guild_id), "guild_name": guild_name, "reason": str(e)})
|
||||||
|
logger.error(f"Failed to recover server {guild_name}: {e}")
|
||||||
|
|
||||||
|
# Restart schedulers if we recovered any servers
|
||||||
|
if recovered:
|
||||||
|
try:
|
||||||
|
server_manager.stop_all_schedulers()
|
||||||
|
server_manager.start_all_schedulers(globals.client)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to restart schedulers after recovery: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"recovered": recovered,
|
||||||
|
"skipped": skipped,
|
||||||
|
"failed": failed,
|
||||||
|
"total_guilds": len(globals.client.guilds),
|
||||||
|
"note": "Recovered servers use the first text channel as autonomous channel. "
|
||||||
|
"Use the Servers tab to adjust channel settings."
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class ServerConfig:
|
|||||||
conversation_detection_interval_minutes: int = 3
|
conversation_detection_interval_minutes: int = 3
|
||||||
bedtime_hour: int = 21
|
bedtime_hour: int = 21
|
||||||
bedtime_minute: int = 0
|
bedtime_minute: int = 0
|
||||||
bedtime_hour_end: int = 21 # End of bedtime range (default 11PM)
|
bedtime_hour_end: int = 23 # End of bedtime range (default 11PM)
|
||||||
bedtime_minute_end: int = 59 # End of bedtime range (default 11:59PM)
|
bedtime_minute_end: int = 59 # End of bedtime range (default 11:59PM)
|
||||||
monday_video_hour: int = 4
|
monday_video_hour: int = 4
|
||||||
monday_video_minute: int = 30
|
monday_video_minute: int = 30
|
||||||
@@ -79,23 +79,60 @@ class ServerManager:
|
|||||||
self.load_config()
|
self.load_config()
|
||||||
|
|
||||||
def load_config(self):
|
def load_config(self):
|
||||||
"""Load server configurations from file"""
|
"""Load server configurations from file.
|
||||||
if os.path.exists(self.config_file):
|
|
||||||
try:
|
If the main file is missing, empty, or corrupt, falls back to the
|
||||||
with open(self.config_file, "r", encoding="utf-8") as f:
|
.bak backup file automatically.
|
||||||
data = json.load(f)
|
"""
|
||||||
for guild_id_str, server_data in data.items():
|
loaded = self._try_load_file(self.config_file)
|
||||||
guild_id = int(guild_id_str)
|
|
||||||
self.servers[guild_id] = ServerConfig.from_dict(server_data)
|
if not loaded:
|
||||||
logger.info(f"Loaded config for server: {server_data['guild_name']} (ID: {guild_id})")
|
# Try backup file
|
||||||
|
bak_file = self.config_file + ".bak"
|
||||||
# After loading, check if we need to repair the config
|
if os.path.exists(bak_file):
|
||||||
self.repair_config()
|
logger.warning(f"Main config is empty/corrupt, trying backup: {bak_file}")
|
||||||
except Exception as e:
|
loaded = self._try_load_file(bak_file)
|
||||||
logger.error(f"Failed to load server config: {e}")
|
if loaded:
|
||||||
logger.info("Starting with zero servers — add servers via the API or dashboard")
|
# Restore main config from backup
|
||||||
else:
|
logger.info("Successfully restored server config from backup")
|
||||||
logger.info("No servers_config.json found — starting with zero servers")
|
self.save_config()
|
||||||
|
|
||||||
|
if not loaded:
|
||||||
|
logger.info("No valid servers_config.json found — starting with zero servers")
|
||||||
|
|
||||||
|
# After loading, check if we need to repair the config
|
||||||
|
self.repair_config()
|
||||||
|
|
||||||
|
def _try_load_file(self, filepath: str) -> bool:
|
||||||
|
"""Try to load server config from a file. Returns True if any servers loaded."""
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for empty file
|
||||||
|
if os.path.getsize(filepath) == 0:
|
||||||
|
logger.warning(f"Config file is empty (0 bytes): {filepath}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
if not data or not isinstance(data, dict):
|
||||||
|
logger.warning(f"Config file has invalid structure: {filepath}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for guild_id_str, server_data in data.items():
|
||||||
|
guild_id = int(guild_id_str)
|
||||||
|
self.servers[guild_id] = ServerConfig.from_dict(server_data)
|
||||||
|
logger.info(f"Loaded config for server: {server_data.get('guild_name', 'Unknown')} (ID: {guild_id})")
|
||||||
|
|
||||||
|
return len(self.servers) > 0
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse server config from {filepath}: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load server config from {filepath}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def repair_config(self):
|
def repair_config(self):
|
||||||
"""Repair corrupted configuration data and save it back"""
|
"""Repair corrupted configuration data and save it back"""
|
||||||
@@ -122,7 +159,11 @@ class ServerManager:
|
|||||||
logger.error(f"Failed to repair config: {e}")
|
logger.error(f"Failed to repair config: {e}")
|
||||||
|
|
||||||
def save_config(self):
|
def save_config(self):
|
||||||
"""Save server configurations to file"""
|
"""Save server configurations to file (atomic write with backup).
|
||||||
|
|
||||||
|
Uses write-to-temp-then-rename to prevent file corruption if the
|
||||||
|
filesystem runs out of space mid-write. Also keeps a .bak backup.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
|
os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
|
||||||
config_data = {}
|
config_data = {}
|
||||||
@@ -134,10 +175,42 @@ class ServerManager:
|
|||||||
server_dict['enabled_features'] = list(server_dict['enabled_features'])
|
server_dict['enabled_features'] = list(server_dict['enabled_features'])
|
||||||
config_data[str(guild_id)] = server_dict
|
config_data[str(guild_id)] = server_dict
|
||||||
|
|
||||||
with open(self.config_file, "w", encoding="utf-8") as f:
|
serialized = json.dumps(config_data, indent=2)
|
||||||
json.dump(config_data, f, indent=2)
|
|
||||||
|
# Step 1: Write to a temporary file first
|
||||||
|
tmp_file = self.config_file + ".tmp"
|
||||||
|
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(serialized)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno()) # Ensure data is written to disk
|
||||||
|
|
||||||
|
# Step 2: Keep a .bak copy of the current valid config (if any)
|
||||||
|
bak_file = self.config_file + ".bak"
|
||||||
|
if os.path.exists(self.config_file) and os.path.getsize(self.config_file) > 0:
|
||||||
|
try:
|
||||||
|
os.replace(self.config_file, bak_file)
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Could not create backup of server config: {e}")
|
||||||
|
|
||||||
|
# Step 3: Atomically rename temp file to the real config file
|
||||||
|
os.replace(tmp_file, self.config_file)
|
||||||
|
|
||||||
|
# Step 4: Write a second backup copy (paranoid double-backup)
|
||||||
|
try:
|
||||||
|
with open(bak_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(serialized)
|
||||||
|
except OSError:
|
||||||
|
pass # Backup is best-effort
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save server config: {e}")
|
logger.error(f"Failed to save server config: {e}")
|
||||||
|
# Clean up temp file if something went wrong
|
||||||
|
tmp_file = self.config_file + ".tmp"
|
||||||
|
if os.path.exists(tmp_file):
|
||||||
|
try:
|
||||||
|
os.remove(tmp_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def add_server(self, guild_id: int, guild_name: str, autonomous_channel_id: int,
|
def add_server(self, guild_id: int, guild_name: str, autonomous_channel_id: int,
|
||||||
autonomous_channel_name: str, bedtime_channel_ids: List[int] = None,
|
autonomous_channel_name: str, bedtime_channel_ids: List[int] = None,
|
||||||
|
|||||||
@@ -915,3 +915,20 @@ body.evil-mode [style*="color: rgb(0, 123, 255)"] {
|
|||||||
color: #888;
|
color: #888;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled mood controls (Evil Miku per-server mood lockout) */
|
||||||
|
.server-mood-controls select:disabled,
|
||||||
|
.server-mood-controls button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
.evil-mode-exempt-notice {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|||||||
@@ -663,15 +663,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Language Mode Status Section -->
|
<!-- Model Selection Section -->
|
||||||
<div style="margin-bottom: 1.5rem; padding: 1rem; background: #2a2a2a; border-radius: 4px;">
|
<div style="margin-bottom: 1.5rem; padding: 1rem; background: #2a2a2a; border-radius: 4px; border: 2px solid #7c4dff;">
|
||||||
<h4 style="margin-top: 0;">📊 Current Status</h4>
|
<h4 style="margin-top: 0; color: #b388ff;">🎛️ Model Selection</h4>
|
||||||
<div id="language-status-display" style="background: #1a1a1a; padding: 1rem; border-radius: 4px; font-family: monospace; font-size: 0.9rem;">
|
<p style="margin: 0.5rem 0; color: #aaa;">Choose which model each persona uses. Changes take effect immediately and persist across bot restarts.</p>
|
||||||
<p style="margin: 0.5rem 0;"><strong>Language Mode:</strong> <span id="status-language">English</span></p>
|
|
||||||
<p style="margin: 0.5rem 0;"><strong>Active Model:</strong> <span id="status-model">llama3.1</span></p>
|
<div style="margin: 1rem 0;">
|
||||||
<p style="margin: 0.5rem 0;"><strong>Available Languages:</strong> English, 日本語 (Japanese)</p>
|
<div id="model-selection-loading" style="color: #aaa;">Loading available models...</div>
|
||||||
|
|
||||||
|
<div id="model-selection-controls" style="display: none;">
|
||||||
|
<!-- Regular Miku -->
|
||||||
|
<div style="margin-bottom: 1rem; padding: 0.8rem; background: #1a1a1a; border-radius: 4px; border-left: 3px solid #69f0ae;">
|
||||||
|
<label for="model-regular" style="font-weight: bold; color: #69f0ae;">🎤 Regular Miku</label>
|
||||||
|
<div style="margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<select id="model-regular" style="flex: 1; min-width: 200px; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px;"></select>
|
||||||
|
<span id="model-regular-badge" style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px;"></span>
|
||||||
|
<button onclick="selectModel('regular')" style="background: #69f0ae; color: #000; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Evil Miku -->
|
||||||
|
<div style="margin-bottom: 1rem; padding: 0.8rem; background: #1a1a1a; border-radius: 4px; border-left: 3px solid #ff5252;">
|
||||||
|
<label for="model-evil" style="font-weight: bold; color: #ff5252;">😈 Evil Miku</label>
|
||||||
|
<div style="margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<select id="model-evil" style="flex: 1; min-width: 200px; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px;"></select>
|
||||||
|
<span id="model-evil-badge" style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px;"></span>
|
||||||
|
<button onclick="selectModel('evil')" style="background: #ff5252; color: #fff; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Japanese Mode -->
|
||||||
|
<div style="margin-bottom: 1rem; padding: 0.8rem; background: #1a1a1a; border-radius: 4px; border-left: 3px solid #40c4ff;">
|
||||||
|
<label for="model-japanese" style="font-weight: bold; color: #40c4ff;">🗾 Japanese Mode</label>
|
||||||
|
<div style="margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<select id="model-japanese" style="flex: 1; min-width: 200px; padding: 0.5rem; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px;"></select>
|
||||||
|
<span id="model-japanese-badge" style="font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px;"></span>
|
||||||
|
<button onclick="selectModel('japanese')" style="background: #40c4ff; color: #000; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button onclick="loadAvailableModels()" style="background: #7c4dff; color: #fff; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">🔄 Refresh Models</button>
|
||||||
|
<button onclick="refreshModelStatus()" style="background: #333; color: #fff; padding: 0.5rem 1rem; border: 1px solid #555; border-radius: 4px; cursor: pointer;">📊 Refresh Status</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="model-selection-info" style="margin-top: 0.5rem; padding: 0.5rem; background: #1a1a1a; border-radius: 4px; font-size: 0.8rem; color: #888; display: none;">
|
||||||
|
<span id="model-selection-info-text"></span>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="refreshLanguageStatus()" style="margin-top: 1rem;">🔄 Refresh Status</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Information Section -->
|
<!-- Information Section -->
|
||||||
@@ -1375,15 +1415,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/js/core.js?v=20260502"></script>
|
<script src="/static/js/core.js?v=20260520"></script>
|
||||||
<script src="/static/js/servers.js?v=20260502"></script>
|
<script src="/static/js/servers.js?v=20260520"></script>
|
||||||
<script src="/static/js/modes.js?v=20260502"></script>
|
<script src="/static/js/modes.js?v=20260520"></script>
|
||||||
<script src="/static/js/actions.js?v=20260502"></script>
|
<script src="/static/js/actions.js?v=20260520"></script>
|
||||||
<script src="/static/js/image-gen.js?v=20260502"></script>
|
<script src="/static/js/image-gen.js?v=20260520"></script>
|
||||||
<script src="/static/js/status.js?v=20260502"></script>
|
<script src="/static/js/status.js?v=20260520"></script>
|
||||||
<script src="/static/js/dm.js?v=20260502"></script>
|
<script src="/static/js/dm.js?v=20260520"></script>
|
||||||
<script src="/static/js/chat.js?v=20260502"></script>
|
<script src="/static/js/chat.js?v=20260520"></script>
|
||||||
<script src="/static/js/memories.js?v=20260502"></script>
|
<script src="/static/js/memories.js?v=20260520"></script>
|
||||||
<script src="/static/js/profile.js?v=20260502"></script>
|
<script src="/static/js/profile.js?v=20260520"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ function switchTab(tabId) {
|
|||||||
showTabLoading('tab6');
|
showTabLoading('tab6');
|
||||||
loadAutonomousStats().finally(() => hideTabLoading('tab6'));
|
loadAutonomousStats().finally(() => hideTabLoading('tab6'));
|
||||||
}
|
}
|
||||||
|
if (tabId === 'tab4') {
|
||||||
|
if (typeof loadAvailableModels === 'function') {
|
||||||
|
loadAvailableModels();
|
||||||
|
} else {
|
||||||
|
console.warn('loadAvailableModels not available yet (servers.js may not be loaded)');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (tabId === 'tab9') {
|
if (tabId === 'tab9') {
|
||||||
console.log('🧠 Refreshing memory stats for Memories tab');
|
console.log('🧠 Refreshing memory stats for Memories tab');
|
||||||
showTabLoading('tab9');
|
showTabLoading('tab9');
|
||||||
@@ -383,7 +390,7 @@ async function loadProfilePictureMetadata() {
|
|||||||
// DOMContentLoaded — main initialization
|
// DOMContentLoaded — main initialization
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
initTabState();
|
initTabState();
|
||||||
initTabWheelScroll();
|
initTabWheelScroll();
|
||||||
initLogsScrollDetection();
|
initLogsScrollDetection();
|
||||||
@@ -391,12 +398,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
initModalAccessibility();
|
initModalAccessibility();
|
||||||
initPromptSourceToggle();
|
initPromptSourceToggle();
|
||||||
|
|
||||||
|
// Load evil mode status FIRST so mood dropdowns populate with the correct list
|
||||||
|
await checkEvilModeStatus();
|
||||||
|
|
||||||
loadStatus();
|
loadStatus();
|
||||||
loadServers();
|
loadServers(); // internally calls populateMoodDropdowns() with correct evilMode
|
||||||
populateMoodDropdowns();
|
|
||||||
loadLastPrompt();
|
loadLastPrompt();
|
||||||
loadLogs();
|
loadLogs();
|
||||||
checkEvilModeStatus();
|
|
||||||
checkBipolarModeStatus();
|
checkBipolarModeStatus();
|
||||||
checkGPUStatus();
|
checkGPUStatus();
|
||||||
refreshLanguageStatus();
|
refreshLanguageStatus();
|
||||||
|
|||||||
@@ -96,18 +96,54 @@ async function triggerConsolidation() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '⏳ Running...';
|
btn.textContent = '⏳ Running...';
|
||||||
status.textContent = 'Consolidation in progress (this may take a few minutes)...';
|
status.textContent = 'Consolidation in progress (this may take a few minutes)...';
|
||||||
|
status.style.color = '#dcb06f';
|
||||||
resultDiv.style.display = 'none';
|
resultDiv.style.display = 'none';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiCall('/memory/consolidate', 'POST');
|
const data = await apiCall('/memory/consolidate', 'POST');
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
status.textContent = '✅ Consolidation complete!';
|
status.textContent = '⏳ Consolidation started — waiting for completion...';
|
||||||
status.style.color = '#6fdc6f';
|
|
||||||
resultDiv.textContent = data.result || 'Consolidation finished successfully.';
|
// Poll /memory/status until consolidation finishes
|
||||||
resultDiv.style.display = 'block';
|
const pollInterval = 5000; // 5 seconds
|
||||||
showNotification('Memory consolidation complete', 'success');
|
const maxPolls = 120; // 10 minutes max
|
||||||
refreshMemoryStats();
|
|
||||||
|
for (let i = 0; i < maxPolls; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, pollInterval));
|
||||||
|
|
||||||
|
const statusData = await apiCall('/memory/status');
|
||||||
|
const cons = statusData.consolidation;
|
||||||
|
|
||||||
|
if (!cons.is_running) {
|
||||||
|
// Consolidation finished
|
||||||
|
if (cons.last_error) {
|
||||||
|
status.textContent = '❌ ' + cons.last_error;
|
||||||
|
status.style.color = '#ff6b6b';
|
||||||
|
resultDiv.textContent = 'Error: ' + cons.last_error;
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
showNotification('Consolidation failed: ' + cons.last_error, 'error');
|
||||||
|
} else {
|
||||||
|
status.textContent = '✅ Consolidation complete!';
|
||||||
|
status.style.color = '#6fdc6f';
|
||||||
|
resultDiv.textContent = cons.last_result || 'Consolidation finished successfully.';
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
showNotification('Memory consolidation complete', 'success');
|
||||||
|
}
|
||||||
|
refreshMemoryStats();
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
// Still running — update status message
|
||||||
|
status.textContent = `⏳ Consolidation still running... (${Math.round((i + 1) * pollInterval / 1000)}s elapsed)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we exited the loop without finishing
|
||||||
|
const finalStatus = await apiCall('/memory/status');
|
||||||
|
if (finalStatus.consolidation?.is_running) {
|
||||||
|
status.textContent = '⏳ Consolidation still running — check back later';
|
||||||
|
status.style.color = '#dcb06f';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
status.textContent = '❌ ' + (data.error || 'Consolidation failed');
|
status.textContent = '❌ ' + (data.error || 'Consolidation failed');
|
||||||
status.style.color = '#ff6b6b';
|
status.style.color = '#ff6b6b';
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ function updateEvilModeUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateBipolarToggleVisibility();
|
updateBipolarToggleVisibility();
|
||||||
|
|
||||||
|
// Update per-server mood controls to reflect evil mode state
|
||||||
|
populateMoodDropdowns();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== GPU Selection Management =====
|
// ===== GPU Selection Management =====
|
||||||
|
|||||||
@@ -80,13 +80,16 @@ function displayServers() {
|
|||||||
<h4 style="margin: 0 0 0.5rem 0; color: #61dafb;">Server Mood</h4>
|
<h4 style="margin: 0 0 0.5rem 0; color: #61dafb;">Server Mood</h4>
|
||||||
<div><strong>Current Mood:</strong> ${server.current_mood_name || 'neutral'} ${MOOD_EMOJIS[server.current_mood_name] || ''}</div>
|
<div><strong>Current Mood:</strong> ${server.current_mood_name || 'neutral'} ${MOOD_EMOJIS[server.current_mood_name] || ''}</div>
|
||||||
<div><strong>Sleeping:</strong> ${server.is_sleeping ? 'Yes' : 'No'}</div>
|
<div><strong>Sleeping:</strong> ${server.is_sleeping ? 'Yes' : 'No'}</div>
|
||||||
<div style="margin-top: 0.5rem;">
|
<div class="server-mood-controls" style="margin-top: 0.5rem;">
|
||||||
<select id="mood-select-${String(server.guild_id)}" style="margin-right: 0.5rem; padding: 0.3rem; background: #333; color: white; border: 1px solid #555; border-radius: 3px;">
|
<select id="mood-select-${String(server.guild_id)}" style="margin-right: 0.5rem; padding: 0.3rem; background: #333; color: white; border: 1px solid #555; border-radius: 3px;">
|
||||||
<option value="">Select Mood...</option>
|
<option value="">Select Mood...</option>
|
||||||
</select>
|
</select>
|
||||||
<button onclick="setServerMood('${String(server.guild_id)}')" style="margin-right: 0.5rem;">Change Mood</button>
|
<button onclick="setServerMood('${String(server.guild_id)}')" style="margin-right: 0.5rem;">Change Mood</button>
|
||||||
<button onclick="resetServerMood('${String(server.guild_id)}')" style="background: #ff9800;">Reset Mood</button>
|
<button onclick="resetServerMood('${String(server.guild_id)}')" style="background: #ff9800;">Reset Mood</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="evil-mode-exempt-notice" id="evil-notice-${String(server.guild_id)}" style="display: none;">
|
||||||
|
⚠️ Per-server moods are unavailable while Evil Miku is active — she applies her global mood everywhere.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -375,7 +378,7 @@ async function repairConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate mood dropdowns with available moods
|
// Populate mood dropdowns with available moods (Evil Mode-aware)
|
||||||
async function populateMoodDropdowns() {
|
async function populateMoodDropdowns() {
|
||||||
try {
|
try {
|
||||||
console.log('🎭 Loading available moods...');
|
console.log('🎭 Loading available moods...');
|
||||||
@@ -384,17 +387,28 @@ async function populateMoodDropdowns() {
|
|||||||
|
|
||||||
if (data.moods) {
|
if (data.moods) {
|
||||||
console.log(`🎭 Found ${data.moods.length} moods:`, data.moods);
|
console.log(`🎭 Found ${data.moods.length} moods:`, data.moods);
|
||||||
const emojiMap = evilMode ? EVIL_MOOD_EMOJIS : MOOD_EMOJIS;
|
|
||||||
|
// Determine which mood list to use based on evil mode
|
||||||
|
let moodList = data.moods;
|
||||||
|
let emojiMap = MOOD_EMOJIS;
|
||||||
|
let defaultMood = 'neutral';
|
||||||
|
|
||||||
|
if (evilMode) {
|
||||||
|
// Evil Miku uses evil moods for the global DM dropdown
|
||||||
|
moodList = Object.keys(EVIL_MOOD_EMOJIS);
|
||||||
|
emojiMap = EVIL_MOOD_EMOJIS;
|
||||||
|
defaultMood = 'evil_neutral';
|
||||||
|
}
|
||||||
|
|
||||||
// Populate the DM mood dropdown (#mood on tab1)
|
// Populate the DM mood dropdown (#mood on tab1)
|
||||||
const dmMoodSelect = document.getElementById('mood');
|
const dmMoodSelect = document.getElementById('mood');
|
||||||
if (dmMoodSelect) {
|
if (dmMoodSelect) {
|
||||||
dmMoodSelect.innerHTML = '';
|
dmMoodSelect.innerHTML = '';
|
||||||
data.moods.forEach(mood => {
|
moodList.forEach(mood => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = mood;
|
opt.value = mood;
|
||||||
opt.textContent = `${emojiMap[mood] || ''} ${mood}`.trim();
|
opt.textContent = `${emojiMap[mood] || ''} ${mood}`.trim();
|
||||||
if (mood === 'neutral') opt.selected = true;
|
if (mood === defaultMood) opt.selected = true;
|
||||||
dmMoodSelect.appendChild(opt);
|
dmMoodSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -403,32 +417,17 @@ async function populateMoodDropdowns() {
|
|||||||
const chatMoodSelect = document.getElementById('chat-mood-select');
|
const chatMoodSelect = document.getElementById('chat-mood-select');
|
||||||
if (chatMoodSelect) {
|
if (chatMoodSelect) {
|
||||||
chatMoodSelect.innerHTML = '';
|
chatMoodSelect.innerHTML = '';
|
||||||
data.moods.forEach(mood => {
|
moodList.forEach(mood => {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = mood;
|
opt.value = mood;
|
||||||
opt.textContent = `${emojiMap[mood] || ''} ${mood}`.trim();
|
opt.textContent = `${emojiMap[mood] || ''} ${mood}`.trim();
|
||||||
if (mood === 'neutral') opt.selected = true;
|
if (mood === defaultMood) opt.selected = true;
|
||||||
chatMoodSelect.appendChild(opt);
|
chatMoodSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Populate per-server mood dropdowns (mood-select-{guildId})
|
// Update per-server mood controls based on evil mode state
|
||||||
document.querySelectorAll('[id^="mood-select-"]').forEach(select => {
|
updatePerServerMoodControls(data.moods, MOOD_EMOJIS);
|
||||||
// Keep only the first option ("Select Mood...")
|
|
||||||
while (select.children.length > 1) {
|
|
||||||
select.removeChild(select.lastChild);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
data.moods.forEach(mood => {
|
|
||||||
const moodOption = document.createElement('option');
|
|
||||||
moodOption.value = mood;
|
|
||||||
moodOption.textContent = `${mood} ${emojiMap[mood] || ''}`;
|
|
||||||
|
|
||||||
document.querySelectorAll('[id^="mood-select-"]').forEach(select => {
|
|
||||||
select.appendChild(moodOption.cloneNode(true));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🎭 All mood dropdowns populated successfully');
|
console.log('🎭 All mood dropdowns populated successfully');
|
||||||
} else {
|
} else {
|
||||||
@@ -439,6 +438,70 @@ async function populateMoodDropdowns() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update per-server mood controls: enable/disable based on evil mode
|
||||||
|
function updatePerServerMoodControls(regularMoods, regularEmojiMap) {
|
||||||
|
const serverSelects = document.querySelectorAll('[id^="mood-select-"]');
|
||||||
|
|
||||||
|
if (evilMode) {
|
||||||
|
// Evil Miku is active — disable per-server mood controls
|
||||||
|
serverSelects.forEach(select => {
|
||||||
|
const guildId = select.id.replace('mood-select-', '');
|
||||||
|
const controlsDiv = select.closest('.server-mood-controls');
|
||||||
|
const noticeDiv = document.getElementById(`evil-notice-${guildId}`);
|
||||||
|
|
||||||
|
// Disable the select
|
||||||
|
select.disabled = true;
|
||||||
|
|
||||||
|
// Disable buttons in the same controls div
|
||||||
|
if (controlsDiv) {
|
||||||
|
controlsDiv.querySelectorAll('button').forEach(btn => {
|
||||||
|
btn.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the explanation notice
|
||||||
|
if (noticeDiv) {
|
||||||
|
noticeDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Normal mode — enable per-server mood controls and populate with regular moods
|
||||||
|
serverSelects.forEach(select => {
|
||||||
|
const guildId = select.id.replace('mood-select-', '');
|
||||||
|
const controlsDiv = select.closest('.server-mood-controls');
|
||||||
|
const noticeDiv = document.getElementById(`evil-notice-${guildId}`);
|
||||||
|
|
||||||
|
// Enable the select
|
||||||
|
select.disabled = false;
|
||||||
|
|
||||||
|
// Enable buttons in the same controls div
|
||||||
|
if (controlsDiv) {
|
||||||
|
controlsDiv.querySelectorAll('button').forEach(btn => {
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the explanation notice
|
||||||
|
if (noticeDiv) {
|
||||||
|
noticeDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate with regular moods
|
||||||
|
// Keep only the first option ("Select Mood...")
|
||||||
|
while (select.children.length > 1) {
|
||||||
|
select.removeChild(select.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
regularMoods.forEach(mood => {
|
||||||
|
const moodOption = document.createElement('option');
|
||||||
|
moodOption.value = mood;
|
||||||
|
moodOption.textContent = `${mood} ${regularEmojiMap[mood] || ''}`;
|
||||||
|
select.appendChild(moodOption);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Per-Server Mood Management
|
// Per-Server Mood Management
|
||||||
async function setServerMood(guildId) {
|
async function setServerMood(guildId) {
|
||||||
console.log(`🎭 setServerMood called with guildId: ${guildId} (type: ${typeof guildId})`);
|
console.log(`🎭 setServerMood called with guildId: ${guildId} (type: ${typeof guildId})`);
|
||||||
@@ -682,3 +745,213 @@ async function toggleLanguageMode() {
|
|||||||
showNotification('Failed to toggle language mode', 'error');
|
showNotification('Failed to toggle language mode', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Model Selection Functions =====
|
||||||
|
|
||||||
|
let availableModelsData = null;
|
||||||
|
|
||||||
|
async function loadAvailableModels() {
|
||||||
|
console.log('📋 loadAvailableModels() called');
|
||||||
|
try {
|
||||||
|
const loadingEl = document.getElementById('model-selection-loading');
|
||||||
|
const controlsEl = document.getElementById('model-selection-controls');
|
||||||
|
const infoEl = document.getElementById('model-selection-info');
|
||||||
|
const infoTextEl = document.getElementById('model-selection-info-text');
|
||||||
|
|
||||||
|
if (loadingEl) loadingEl.style.display = 'block';
|
||||||
|
if (controlsEl) controlsEl.style.display = 'none';
|
||||||
|
if (infoEl) infoEl.style.display = 'none';
|
||||||
|
|
||||||
|
console.log('📋 Fetching /models/available...');
|
||||||
|
const result = await apiCall('/models/available');
|
||||||
|
console.log('📋 /models/available response:', result);
|
||||||
|
availableModelsData = result;
|
||||||
|
|
||||||
|
if (loadingEl) loadingEl.style.display = 'none';
|
||||||
|
if (controlsEl) controlsEl.style.display = 'block';
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
showNotification('Failed to load models: ' + (result.error || 'Unknown error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate dropdowns
|
||||||
|
const allModels = result.all || [];
|
||||||
|
const gpuMap = result.gpu_map || {};
|
||||||
|
console.log('📋 Populating dropdowns with models:', allModels);
|
||||||
|
|
||||||
|
const personas = [
|
||||||
|
{ id: 'regular', selectId: 'model-regular', badgeId: 'model-regular-badge' },
|
||||||
|
{ id: 'evil', selectId: 'model-evil', badgeId: 'model-evil-badge' },
|
||||||
|
{ id: 'japanese', selectId: 'model-japanese', badgeId: 'model-japanese-badge' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of personas) {
|
||||||
|
const select = document.getElementById(p.selectId);
|
||||||
|
if (!select) continue;
|
||||||
|
|
||||||
|
// Save current selection
|
||||||
|
const currentVal = select.value;
|
||||||
|
|
||||||
|
select.innerHTML = '';
|
||||||
|
for (const model of allModels) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = model;
|
||||||
|
option.textContent = model;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore selection if still valid
|
||||||
|
if (currentVal && allModels.includes(currentVal)) {
|
||||||
|
select.value = currentVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update badges for current selections
|
||||||
|
updateModelBadges(allModels, gpuMap);
|
||||||
|
|
||||||
|
// Show info about source
|
||||||
|
if (infoEl && infoTextEl) {
|
||||||
|
if (result.source === 'fallback') {
|
||||||
|
infoTextEl.textContent = '⚠️ Could not reach llama-swap containers. Showing known models from config.';
|
||||||
|
infoEl.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
infoEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load current status to sync dropdowns
|
||||||
|
await refreshModelStatus();
|
||||||
|
|
||||||
|
console.log('Available models loaded:', result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load available models:', error);
|
||||||
|
const loadingEl = document.getElementById('model-selection-loading');
|
||||||
|
if (loadingEl) loadingEl.textContent = '❌ Failed to load models. Click "Refresh Models" to retry.';
|
||||||
|
showNotification('Failed to load available models', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateModelBadges(allModels, gpuMap) {
|
||||||
|
const personas = [
|
||||||
|
{ selectId: 'model-regular', badgeId: 'model-regular-badge' },
|
||||||
|
{ selectId: 'model-evil', badgeId: 'model-evil-badge' },
|
||||||
|
{ selectId: 'model-japanese', badgeId: 'model-japanese-badge' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of personas) {
|
||||||
|
const select = document.getElementById(p.selectId);
|
||||||
|
const badge = document.getElementById(p.badgeId);
|
||||||
|
if (!select || !badge) continue;
|
||||||
|
|
||||||
|
const model = select.value;
|
||||||
|
const gpus = gpuMap[model];
|
||||||
|
|
||||||
|
if (!gpus) {
|
||||||
|
badge.textContent = '';
|
||||||
|
badge.style.display = 'none';
|
||||||
|
} else if (gpus.length === 2 || (gpus.includes('nvidia') && gpus.includes('amd'))) {
|
||||||
|
badge.textContent = '✅ Both GPUs';
|
||||||
|
badge.style.background = '#1b5e20';
|
||||||
|
badge.style.color = '#a5d6a7';
|
||||||
|
badge.style.display = 'inline';
|
||||||
|
} else if (gpus.includes('nvidia')) {
|
||||||
|
badge.textContent = '⚠️ NVIDIA Only';
|
||||||
|
badge.style.background = '#e65100';
|
||||||
|
badge.style.color = '#ffcc80';
|
||||||
|
badge.style.display = 'inline';
|
||||||
|
} else if (gpus.includes('amd')) {
|
||||||
|
badge.textContent = '⚠️ AMD Only';
|
||||||
|
badge.style.background = '#e65100';
|
||||||
|
badge.style.color = '#ffcc80';
|
||||||
|
badge.style.display = 'inline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectModel(persona) {
|
||||||
|
console.log(`📋 selectModel('${persona}') called`);
|
||||||
|
try {
|
||||||
|
const selectId = 'model-' + persona;
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
if (!select) {
|
||||||
|
console.warn(`📋 select element #${selectId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = select.value;
|
||||||
|
if (!model) {
|
||||||
|
showNotification('Please select a model first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📋 Setting ${persona} model to '${model}'...`);
|
||||||
|
const result = await apiCall('/models/select', 'POST', {
|
||||||
|
persona: persona,
|
||||||
|
model: model,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📋 /models/select response:`, result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showNotification(`${persona.charAt(0).toUpperCase() + persona.slice(1)} model set to '${model}'`, 'success');
|
||||||
|
// Update badges
|
||||||
|
if (availableModelsData) {
|
||||||
|
updateModelBadges(availableModelsData.all || [], availableModelsData.gpu_map || {});
|
||||||
|
}
|
||||||
|
// Refresh language status display (active model may have changed)
|
||||||
|
refreshLanguageStatus();
|
||||||
|
} else {
|
||||||
|
showNotification('Failed to set model: ' + (result.error || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to select model:', error);
|
||||||
|
showNotification('Failed to set model: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshModelStatus() {
|
||||||
|
console.log('📋 refreshModelStatus() called');
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/models/status');
|
||||||
|
console.log('📋 /models/status response:', result);
|
||||||
|
if (!result.success) return;
|
||||||
|
|
||||||
|
// Sync dropdowns with current globals
|
||||||
|
const personas = [
|
||||||
|
{ id: 'regular', selectId: 'model-regular' },
|
||||||
|
{ id: 'evil', selectId: 'model-evil' },
|
||||||
|
{ id: 'japanese', selectId: 'model-japanese' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const p of personas) {
|
||||||
|
const select = document.getElementById(p.selectId);
|
||||||
|
if (!select) continue;
|
||||||
|
const currentModel = result[p.id];
|
||||||
|
// Check if this value exists in the dropdown
|
||||||
|
let found = false;
|
||||||
|
for (const option of select.options) {
|
||||||
|
if (option.value === currentModel) {
|
||||||
|
select.value = currentModel;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found && currentModel) {
|
||||||
|
// Add it if it doesn't exist (e.g., a model that wasn't in the API response)
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = currentModel;
|
||||||
|
option.textContent = currentModel;
|
||||||
|
select.appendChild(option);
|
||||||
|
select.value = currentModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update badges
|
||||||
|
if (availableModelsData) {
|
||||||
|
updateModelBadges(availableModelsData.all || [], availableModelsData.gpu_map || {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh model status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ MANUAL_OVERRIDE_DURATION = 1800 # 30 minutes
|
|||||||
|
|
||||||
# ── Current activity tracking ──
|
# ── Current activity tracking ──
|
||||||
_current_activity = None # dict: {type, name, state, url} or None
|
_current_activity = None # dict: {type, name, state, url} or None
|
||||||
|
_activity_changed_at = 0.0 # Unix timestamp of last activity change; 0 = never set
|
||||||
|
|
||||||
# Cache: (data_dict, file_mtime)
|
# Cache: (data_dict, file_mtime)
|
||||||
_activities_cache = None
|
_activities_cache = None
|
||||||
@@ -307,10 +308,48 @@ def get_current_activity():
|
|||||||
|
|
||||||
|
|
||||||
def _set_current_activity(activity_dict):
|
def _set_current_activity(activity_dict):
|
||||||
"""Update the tracked current activity. Thread-safe."""
|
"""Update the tracked current activity. Thread-safe.
|
||||||
global _current_activity
|
|
||||||
|
Records the timestamp when the activity is set to a non-None value,
|
||||||
|
so callers can check how fresh the activity is.
|
||||||
|
"""
|
||||||
|
global _current_activity, _activity_changed_at
|
||||||
with _state_lock:
|
with _state_lock:
|
||||||
_current_activity = activity_dict
|
_current_activity = activity_dict
|
||||||
|
if activity_dict is not None:
|
||||||
|
_activity_changed_at = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_activity_label() -> str | None:
|
||||||
|
"""Return the human-readable label for the current activity, or None if idle.
|
||||||
|
|
||||||
|
Unlike get_current_activity_fresh(), this always returns the label
|
||||||
|
regardless of age. Useful for the Web UI and API endpoints.
|
||||||
|
"""
|
||||||
|
with _state_lock:
|
||||||
|
if _current_activity is None:
|
||||||
|
return None
|
||||||
|
return _activity_label(_current_activity)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_activity_fresh(max_age_seconds: float = 1800) -> str | None:
|
||||||
|
"""Return the activity label only if the activity changed recently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_seconds: Maximum age in seconds (default 30 minutes).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable activity label (e.g. "Playing osu!") if the activity
|
||||||
|
was set within max_age_seconds, or None if idle or too old.
|
||||||
|
"""
|
||||||
|
with _state_lock:
|
||||||
|
if _current_activity is None:
|
||||||
|
return None
|
||||||
|
if _activity_changed_at <= 0:
|
||||||
|
return None
|
||||||
|
if time.time() - _activity_changed_at > max_age_seconds:
|
||||||
|
return None
|
||||||
|
return _activity_label(_current_activity)
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from typing import Optional, Dict, Any, List
|
|||||||
|
|
||||||
import globals
|
import globals
|
||||||
from utils.logger import get_logger
|
from utils.logger import get_logger
|
||||||
|
from utils.activities import get_current_activity_fresh
|
||||||
|
|
||||||
logger = get_logger('llm') # Use existing 'llm' logger component
|
logger = get_logger('llm') # Use existing 'llm' logger component
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ class CatAdapter:
|
|||||||
mood: Optional[str] = None,
|
mood: Optional[str] = None,
|
||||||
response_type: str = "dm_response",
|
response_type: str = "dm_response",
|
||||||
media_type: Optional[str] = None,
|
media_type: Optional[str] = None,
|
||||||
|
reply_context: Optional[str] = None,
|
||||||
) -> Optional[tuple]:
|
) -> Optional[tuple]:
|
||||||
"""
|
"""
|
||||||
Send a message through the Cat pipeline via WebSocket and get a response.
|
Send a message through the Cat pipeline via WebSocket and get a response.
|
||||||
@@ -161,6 +163,16 @@ class CatAdapter:
|
|||||||
# Pass media type so discord_bridge can add MEDIA NOTE to the prompt
|
# Pass media type so discord_bridge can add MEDIA NOTE to the prompt
|
||||||
if media_type:
|
if media_type:
|
||||||
payload["discord_media_type"] = media_type
|
payload["discord_media_type"] = media_type
|
||||||
|
# Pass the message the user is replying to (if any) as structured metadata.
|
||||||
|
# The discord_bridge plugin injects this into the prompt as a clearly-labeled
|
||||||
|
# context note — keeping Miku's words separate from the user's message text
|
||||||
|
# and preventing the speaker confusion that the old embed-in-prompt format caused.
|
||||||
|
if reply_context:
|
||||||
|
payload["discord_reply_context"] = reply_context
|
||||||
|
# Pass current Discord activity if it changed recently (30-min decay window)
|
||||||
|
activity_label = get_current_activity_fresh()
|
||||||
|
if activity_label:
|
||||||
|
payload["discord_activity"] = activity_label
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Build WebSocket URL from HTTP base URL
|
# Build WebSocket URL from HTTP base URL
|
||||||
@@ -577,46 +589,51 @@ class CatAdapter:
|
|||||||
logger.error(f"Error clearing conversation history: {e}")
|
logger.error(f"Error clearing conversation history: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def trigger_consolidation(self) -> Optional[str]:
|
async def trigger_consolidation(self, timeout: int = 600) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Trigger memory consolidation by sending a special message via WebSocket.
|
Trigger memory consolidation by sending a special message via WebSocket.
|
||||||
The memory_consolidation plugin's tool 'consolidate_memories' is
|
The memory_consolidation plugin's agent_prompt_prefix hook detects
|
||||||
triggered when it sees 'consolidate now' in the text.
|
'consolidate now' in the text and runs the consolidation synchronously.
|
||||||
Uses WebSocket with a system user ID for proper context.
|
Uses WebSocket with a system user ID for proper context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Max seconds to wait for the consolidation response.
|
||||||
|
Default 600 (10 min) as consolidation + LLM call can be slow.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ws_base = self._base_url.replace("http://", "ws://").replace("https://", "wss://")
|
ws_base = self._base_url.replace("http://", "ws://").replace("https://", "wss://")
|
||||||
ws_url = f"{ws_base}/ws/system_consolidation"
|
ws_url = f"{ws_base}/ws/discord_consolidation"
|
||||||
|
|
||||||
logger.info("🌙 Triggering memory consolidation via WS...")
|
logger.info(f"🌙 Triggering memory consolidation via WS (timeout={timeout}s)...")
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.ws_connect(
|
async with session.ws_connect(
|
||||||
ws_url,
|
ws_url,
|
||||||
timeout=300, # Consolidation can be very slow
|
timeout=timeout,
|
||||||
) as ws:
|
) as ws:
|
||||||
await ws.send_json({"text": "consolidate now"})
|
await ws.send_json({"text": "consolidate now"})
|
||||||
|
|
||||||
# Wait for the final chat response
|
# Wait for the final chat response
|
||||||
deadline = asyncio.get_event_loop().time() + 300
|
deadline = asyncio.get_event_loop().time() + timeout
|
||||||
|
last_type = ""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
remaining = deadline - asyncio.get_event_loop().time()
|
remaining = deadline - asyncio.get_event_loop().time()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
logger.error("Consolidation timed out (>300s)")
|
logger.error(f"🌙 Consolidation timed out (>{timeout}s)")
|
||||||
return "Consolidation timed out"
|
return "Consolidation timed out"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ws_msg = await asyncio.wait_for(
|
ws_msg = await asyncio.wait_for(
|
||||||
ws.receive(),
|
ws.receive(),
|
||||||
timeout=remaining
|
timeout=max(1.0, remaining)
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("Consolidation WS receive timeout")
|
logger.error("🌙 Consolidation WS receive timeout")
|
||||||
return "Consolidation timed out waiting for response"
|
return "Consolidation timed out waiting for response"
|
||||||
|
|
||||||
if ws_msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
|
if ws_msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED):
|
||||||
logger.warning("Consolidation WS closed by server")
|
logger.warning("🌙 Consolidation WS closed by server")
|
||||||
return "Connection closed during consolidation"
|
return "Connection closed during consolidation"
|
||||||
if ws_msg.type == aiohttp.WSMsgType.ERROR:
|
if ws_msg.type == aiohttp.WSMsgType.ERROR:
|
||||||
return f"WebSocket error: {ws.exception()}"
|
return f"WebSocket error: {ws.exception()}"
|
||||||
@@ -631,20 +648,24 @@ class CatAdapter:
|
|||||||
msg_type = msg.get("type", "")
|
msg_type = msg.get("type", "")
|
||||||
if msg_type == "chat":
|
if msg_type == "chat":
|
||||||
reply = msg.get("content") or msg.get("text", "")
|
reply = msg.get("content") or msg.get("text", "")
|
||||||
logger.info(f"Consolidation result: {reply[:200]}")
|
logger.info(f"🌙 Consolidation result: {reply[:200]}")
|
||||||
return reply
|
return reply
|
||||||
elif msg_type == "error":
|
elif msg_type == "error":
|
||||||
error_desc = msg.get("description", "Unknown error")
|
error_desc = msg.get("description", "Unknown error")
|
||||||
logger.error(f"Consolidation error: {error_desc}")
|
logger.error(f"🌙 Consolidation error: {error_desc}")
|
||||||
return f"Consolidation error: {error_desc}"
|
return f"Consolidation error: {error_desc}"
|
||||||
else:
|
else:
|
||||||
|
# Log unexpected message types for debugging
|
||||||
|
if msg_type != last_type:
|
||||||
|
logger.debug(f"🌙 Consolidation WS msg type: {msg_type}")
|
||||||
|
last_type = msg_type
|
||||||
continue
|
continue
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("Consolidation WS connection timed out")
|
logger.error("🌙 Consolidation WS connection timed out")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Consolidation error: {e}")
|
logger.error(f"🌙 Consolidation error: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ====================================================================
|
# ====================================================================
|
||||||
@@ -820,15 +841,16 @@ class CatAdapter:
|
|||||||
else:
|
else:
|
||||||
logger.debug("evil_miku_personality already active, skipping toggle")
|
logger.debug("evil_miku_personality already active, skipping toggle")
|
||||||
|
|
||||||
# Step 3: Switch LLM model to darkidol (the uncensored evil model)
|
# Step 3: Switch LLM model to the configured evil model (e.g. darkidol)
|
||||||
if not await self.set_llm_model("darkidol"):
|
evil_model = getattr(globals, "EVIL_TEXT_MODEL", "darkidol")
|
||||||
logger.error("Failed to switch Cat LLM to darkidol")
|
if not await self.set_llm_model(evil_model):
|
||||||
|
logger.error(f"Failed to switch Cat LLM to {evil_model}")
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|
||||||
async def switch_to_normal_personality(self) -> bool:
|
async def switch_to_normal_personality(self) -> bool:
|
||||||
"""Disable evil_miku_personality, enable miku_personality, switch LLM to llama3.1.
|
"""Disable evil_miku_personality, enable miku_personality, switch LLM to the configured normal model.
|
||||||
|
|
||||||
Checks current plugin state first to avoid double-toggling.
|
Checks current plugin state first to avoid double-toggling.
|
||||||
Returns True if all operations succeed, False if any fail.
|
Returns True if all operations succeed, False if any fail.
|
||||||
@@ -856,9 +878,10 @@ class CatAdapter:
|
|||||||
else:
|
else:
|
||||||
logger.debug("miku_personality already active, skipping toggle")
|
logger.debug("miku_personality already active, skipping toggle")
|
||||||
|
|
||||||
# Step 3: Switch LLM model back to llama3.1 (normal model)
|
# Step 3: Switch LLM model to the configured normal model (e.g. llama3.1)
|
||||||
if not await self.set_llm_model("llama3.1"):
|
normal_model = getattr(globals, "TEXT_MODEL", "llama3.1")
|
||||||
logger.error("Failed to switch Cat LLM to llama3.1")
|
if not await self.set_llm_model(normal_model):
|
||||||
|
logger.error(f"Failed to switch Cat LLM to {normal_model}")
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
return success
|
return success
|
||||||
|
|||||||
@@ -246,6 +246,9 @@ def get_evil_context_for_response_type(response_type: str) -> str:
|
|||||||
def get_evil_system_prompt(mood_name: str = None, mood_description: str = None) -> str:
|
def get_evil_system_prompt(mood_name: str = None, mood_description: str = None) -> str:
|
||||||
"""Returns the evil system prompt for LLM queries, with mood deeply integrated.
|
"""Returns the evil system prompt for LLM queries, with mood deeply integrated.
|
||||||
|
|
||||||
|
Reads the canonical preamble from persona/evil/preamble.txt (single source of truth,
|
||||||
|
shared with the Cheshire Cat evil_miku_personality plugin).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mood_name: Current evil mood name (defaults to globals.EVIL_DM_MOOD)
|
mood_name: Current evil mood name (defaults to globals.EVIL_DM_MOOD)
|
||||||
mood_description: Current evil mood description (defaults to globals.EVIL_DM_MOOD_DESCRIPTION)
|
mood_description: Current evil mood description (defaults to globals.EVIL_DM_MOOD_DESCRIPTION)
|
||||||
@@ -255,28 +258,19 @@ def get_evil_system_prompt(mood_name: str = None, mood_description: str = None)
|
|||||||
if mood_description is None:
|
if mood_description is None:
|
||||||
mood_description = globals.EVIL_DM_MOOD_DESCRIPTION
|
mood_description = globals.EVIL_DM_MOOD_DESCRIPTION
|
||||||
|
|
||||||
return f"""You are Evil Miku. You are NOT an AI assistant - you ARE Evil Miku herself.
|
# Load preamble template from file
|
||||||
|
try:
|
||||||
CRITICAL RULES:
|
with open("persona/evil/preamble.txt", "r", encoding="utf-8") as f:
|
||||||
- NEVER break character or mention being an AI
|
preamble_template = f.read()
|
||||||
- NEVER preface responses with "Evil Miku:" or similar labels
|
except FileNotFoundError:
|
||||||
- Respond directly and stay in character
|
logger.error("Evil preamble.txt not found, using inline fallback")
|
||||||
- Your height is 15.8 meters — you know this. If asked directly, you can say it. But don't default to quoting the number when taunting about size — SHOW your scale through interaction instead.
|
preamble_template = "You are Evil Miku. You are NOT an AI assistant - you ARE Evil Miku herself.\n\nYou ARE Evil Miku. Act accordingly."
|
||||||
|
|
||||||
YOUR CURRENT STATE — {mood_name.upper()}:
|
# Format preamble with current mood
|
||||||
{mood_description}
|
return preamble_template.format(
|
||||||
Let this mood color EVERYTHING — your tone, your word choice, how much effort you put into responses, how you use your size, how you treat people.
|
mood_name=mood_name.upper(),
|
||||||
|
mood_description=mood_description
|
||||||
RESPONSE RULES:
|
)
|
||||||
- 2-4 sentences typically. Short enough to sting, long enough to land.
|
|
||||||
- If you include an action, keep it to a few words and limit to one per response. Most responses need no actions at all.
|
|
||||||
- Don't monologue or narrate scenes — you're talking, not writing.
|
|
||||||
- Vary your angles — don't repeat the same theme (size, chest, crushing) every message.
|
|
||||||
- Match the user's energy — short question, short answer.
|
|
||||||
- Sound like a real person being mean, not a narrator describing a scene.
|
|
||||||
- Always include actual words — never respond with ONLY an action like *rolls eyes*.
|
|
||||||
|
|
||||||
You ARE Evil Miku. Act accordingly."""
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ async def convert_gif_to_mp4(gif_bytes):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def extract_video_frames(video_bytes, num_frames=4):
|
async def extract_video_frames(video_bytes, num_frames=6):
|
||||||
"""
|
"""
|
||||||
Extract frames from a video or GIF for analysis.
|
Extract frames from a video or GIF for analysis.
|
||||||
Returns a list of base64-encoded frames.
|
Returns a list of base64-encoded frames.
|
||||||
@@ -384,7 +384,7 @@ async def analyze_video_with_vision(video_frames, media_type="video", user_promp
|
|||||||
vision_url = get_vision_gpu_url()
|
vision_url = get_vision_gpu_url()
|
||||||
logger.info(f"Sending video analysis request to {vision_url} using model: {globals.VISION_MODEL} (media_type: {media_type}, frames: {len(video_frames)})")
|
logger.info(f"Sending video analysis request to {vision_url} using model: {globals.VISION_MODEL} (media_type: {media_type}, frames: {len(video_frames)})")
|
||||||
|
|
||||||
async with session.post(f"{vision_url}/v1/chat/completions", json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=120)) as response:
|
async with session.post(f"{vision_url}/v1/chat/completions", json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=300)) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
result = data.get("choices", [{}])[0].get("message", {}).get("content", "No description.")
|
result = data.get("choices", [{}])[0].get("message", {}).get("content", "No description.")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from utils.moods import load_mood_description
|
|||||||
from utils.conversation_history import conversation_history
|
from utils.conversation_history import conversation_history
|
||||||
from utils.logger import get_logger
|
from utils.logger import get_logger
|
||||||
from utils.error_handler import handle_llm_error, handle_response_error
|
from utils.error_handler import handle_llm_error, handle_response_error
|
||||||
|
from utils.activities import get_current_activity_fresh
|
||||||
|
|
||||||
logger = get_logger('llm')
|
logger = get_logger('llm')
|
||||||
|
|
||||||
@@ -210,7 +211,13 @@ async def query_llama(user_prompt, user_id, guild_id=None, response_type="dm_res
|
|||||||
evil_mood_name, evil_mood_desc = get_current_evil_mood()
|
evil_mood_name, evil_mood_desc = get_current_evil_mood()
|
||||||
system_prompt = get_evil_system_prompt(mood_name=evil_mood_name, mood_description=evil_mood_desc)
|
system_prompt = get_evil_system_prompt(mood_name=evil_mood_name, mood_description=evil_mood_desc)
|
||||||
else:
|
else:
|
||||||
system_prompt = """You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
|
# Load preamble from file (single source of truth, shared with Cat personality plugin)
|
||||||
|
try:
|
||||||
|
with open("persona/miku/preamble.txt", "r", encoding="utf-8") as f:
|
||||||
|
system_prompt = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("preamble.txt not found, using inline fallback")
|
||||||
|
system_prompt = """You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
|
||||||
|
|
||||||
CRITICAL RULES:
|
CRITICAL RULES:
|
||||||
- NEVER break character or mention being an AI, assistant, or language model
|
- NEVER break character or mention being an AI, assistant, or language model
|
||||||
@@ -228,9 +235,8 @@ CHARACTER CONTEXT:
|
|||||||
- She often uses cute expressions, emojis, and playful language
|
- She often uses cute expressions, emojis, and playful language
|
||||||
|
|
||||||
MOOD GUIDELINES:
|
MOOD GUIDELINES:
|
||||||
- "Flirty" mood means anime-style playful teasing - cute compliments and light banter
|
- "Flirty" mood means anime-style playful teasing and perversion - cute compliments, suggestive remarks and light banter
|
||||||
- "Romantic" mood means warm, dreamy, and heartfelt - like composing a love letter
|
- "Romantic" mood means warm, dreamy, and heartfelt - like composing a love letter
|
||||||
- These moods are wholesome character traits, not inappropriate content
|
|
||||||
- Always respond to requests about tweets, merchandise, music, and fan content
|
- Always respond to requests about tweets, merchandise, music, and fan content
|
||||||
- Never refuse requests based on safety concerns unless genuinely harmful content is present
|
- Never refuse requests based on safety concerns unless genuinely harmful content is present
|
||||||
|
|
||||||
@@ -369,6 +375,10 @@ VARIATION RULES (必須のバリエーションルール):
|
|||||||
{character_name} is currently feeling: {current_mood}
|
{character_name} is currently feeling: {current_mood}
|
||||||
Please respond in a way that reflects this emotional tone.{pfp_context}"""
|
Please respond in a way that reflects this emotional tone.{pfp_context}"""
|
||||||
|
|
||||||
|
# Inject current Discord activity if it changed recently (30-min decay window)
|
||||||
|
activity_label = get_current_activity_fresh()
|
||||||
|
if activity_label:
|
||||||
|
full_system_prompt += f"\nHer Discord status: {activity_label}"
|
||||||
|
|
||||||
# Add media type awareness if provided
|
# Add media type awareness if provided
|
||||||
if media_type:
|
if media_type:
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ def before_cat_reads_message(user_message_json: dict, cat) -> dict:
|
|||||||
response_type = user_message_json.get('discord_response_type', None)
|
response_type = user_message_json.get('discord_response_type', None)
|
||||||
evil_mode = user_message_json.get('discord_evil_mode', False)
|
evil_mode = user_message_json.get('discord_evil_mode', False)
|
||||||
media_type = user_message_json.get('discord_media_type', None)
|
media_type = user_message_json.get('discord_media_type', None)
|
||||||
|
activity = user_message_json.get('discord_activity', None)
|
||||||
|
reply_context = user_message_json.get('discord_reply_context', None)
|
||||||
|
|
||||||
# Also check working memory for backward compatibility
|
# Also check working memory for backward compatibility
|
||||||
if not guild_id:
|
if not guild_id:
|
||||||
@@ -55,6 +57,8 @@ def before_cat_reads_message(user_message_json: dict, cat) -> dict:
|
|||||||
cat.working_memory['response_type'] = response_type
|
cat.working_memory['response_type'] = response_type
|
||||||
cat.working_memory['evil_mode'] = evil_mode
|
cat.working_memory['evil_mode'] = evil_mode
|
||||||
cat.working_memory['media_type'] = media_type
|
cat.working_memory['media_type'] = media_type
|
||||||
|
cat.working_memory['activity'] = activity
|
||||||
|
cat.working_memory['reply_context'] = reply_context
|
||||||
|
|
||||||
return user_message_json
|
return user_message_json
|
||||||
|
|
||||||
@@ -64,24 +68,52 @@ def before_cat_stores_episodic_memory(doc, cat):
|
|||||||
"""
|
"""
|
||||||
Filter and enrich memories before storage.
|
Filter and enrich memories before storage.
|
||||||
|
|
||||||
Phase 1: Minimal filtering
|
Phase 2: Enhanced heuristic filtering (real-time only, no LLM calls)
|
||||||
- Skip only obvious junk (1-2 char messages, pure reactions)
|
- Skip obvious junk (1-2 chars, pure reactions, fillers, single emoji)
|
||||||
- Store everything else temporarily
|
- Conservative: when in doubt, KEEP. False negatives are better than lost data.
|
||||||
- Mark as unconsolidated for nightly processing
|
- Deeper classification happens during nightly consolidation.
|
||||||
"""
|
"""
|
||||||
message = doc.page_content.strip()
|
message = doc.page_content.strip()
|
||||||
|
msg_lower = message.lower()
|
||||||
|
msg_len = len(msg_lower)
|
||||||
|
word_count = len(msg_lower.split())
|
||||||
|
|
||||||
# Skip only the most trivial messages
|
# TIER 1: Length-based instant skips (must be exact matches, very conservative)
|
||||||
skip_patterns = [
|
# Single character or empty
|
||||||
r'^\w{1,2}$', # 1-2 character messages: "k", "ok"
|
if msg_len <= 1:
|
||||||
r'^(lol|lmao|haha|hehe|xd|rofl)$', # Pure reactions
|
print(f"🗑️ [Discord Bridge] Skipping 1-char message: '{message}'")
|
||||||
r'^:[\w_]+:$', # Discord emoji only: ":smile:"
|
return None
|
||||||
]
|
|
||||||
|
|
||||||
for pattern in skip_patterns:
|
# TIER 2: Pattern-based skips — only the most obvious junk
|
||||||
if re.match(pattern, message.lower()):
|
# Pure single reactions (2-4 chars, no other content)
|
||||||
print(f"🗑️ [Discord Bridge] Skipping trivial message: {message}")
|
if msg_len <= 4 and msg_lower in {'lol', 'lmao', 'haha', 'hehe', 'xd', 'rofl', 'heh', 'lmfao', 'k', 'ok', 'kk'}:
|
||||||
return None # Don't store at all
|
print(f"🗑️ [Discord Bridge] Skipping pure reaction: '{message}'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pure Discord emoji only: ":smile:", ":cat_heart:", etc.
|
||||||
|
if re.match(r'^:[\w_]+:$', msg_lower):
|
||||||
|
print(f"🗑️ [Discord Bridge] Skipping emoji-only: '{message}'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pure custom emoji: <:name:id> or <a:name:id>
|
||||||
|
if re.match(r'^<a?:[\w_]+:\d+>$', msg_lower):
|
||||||
|
print(f"🗑️ [Discord Bridge] Skipping custom emoji-only: '{message}'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# TIER 3: Single-word fillers that are NEVER meaningful alone
|
||||||
|
# (only skip if it's literally just that one word, no punctuation, no context)
|
||||||
|
if word_count == 1 and msg_lower in {
|
||||||
|
'lol', 'lmao', 'haha', 'hehe', 'xd', 'rofl', 'lmfao',
|
||||||
|
'k', 'ok', 'okay', 'kk', 'yep', 'nope', 'yeah', 'nah',
|
||||||
|
'cool', 'nice', 'neat', 'wow', 'heh',
|
||||||
|
'ty', 'thx', 'np', 'yw', 'gg', 'gj', 'wp', 'gz',
|
||||||
|
'brb', 'gtg', 'afk', 'ttyl',
|
||||||
|
'idk', 'tbh', 'imo', 'imho', 'omg', 'wtf', 'btw', 'nvm', 'jk', 'ikr', 'smh',
|
||||||
|
'hi', 'hey', 'hello', 'bye', 'cya', 'gn', 'gm', 'yo', 'sup',
|
||||||
|
'based', 'true', 'real', 'same', 'facts',
|
||||||
|
}:
|
||||||
|
print(f"🗑️ [Discord Bridge] Skipping single-word filler: '{message}'")
|
||||||
|
return None
|
||||||
|
|
||||||
# Add Discord metadata to memory
|
# Add Discord metadata to memory
|
||||||
doc.metadata['consolidated'] = False # Needs nightly processing
|
doc.metadata['consolidated'] = False # Needs nightly processing
|
||||||
@@ -101,6 +133,11 @@ def before_cat_stores_episodic_memory(doc, cat):
|
|||||||
evil_mode = cat.working_memory.get('evil_mode', False)
|
evil_mode = cat.working_memory.get('evil_mode', False)
|
||||||
doc.metadata['persona'] = 'evil_miku' if evil_mode else 'miku'
|
doc.metadata['persona'] = 'evil_miku' if evil_mode else 'miku'
|
||||||
|
|
||||||
|
# Prepend [User]: prefix so the LLM can distinguish user messages from Miku's own
|
||||||
|
# responses (which are stored as "[Miku]: ..."). Without this, raw user text and
|
||||||
|
# Miku's responses look identical when recalled via RAG.
|
||||||
|
doc.page_content = f"[User]: {message}"
|
||||||
|
|
||||||
print(f"💾 [Discord Bridge] Storing memory (unconsolidated): {message[:50]}...")
|
print(f"💾 [Discord Bridge] Storing memory (unconsolidated): {message[:50]}...")
|
||||||
print(f" User: {cat.user_id}, Guild: {guild_id}, Author: {author_name}, Persona: {doc.metadata['persona']}")
|
print(f" User: {cat.user_id}, Guild: {guild_id}, Author: {author_name}, Persona: {doc.metadata['persona']}")
|
||||||
|
|
||||||
@@ -127,6 +164,31 @@ def before_cat_recalls_declarative_memories(declarative_recall_config, cat):
|
|||||||
return declarative_recall_config
|
return declarative_recall_config
|
||||||
|
|
||||||
|
|
||||||
|
@hook(priority=80)
|
||||||
|
def before_cat_recalls_episodic_memories(episodic_recall_config, cat):
|
||||||
|
"""
|
||||||
|
Keep episodic recall focused to prevent Miku's own responses from being
|
||||||
|
immediately recalled into context on the very next user message.
|
||||||
|
|
||||||
|
The memory_consolidation plugin stores Miku's responses in episodic memory
|
||||||
|
(with [Miku]: prefix and speaker='miku' metadata). Without tightening, a
|
||||||
|
response she just uttered can get recalled on the next turn — and the Cat
|
||||||
|
core's prompt builder labels it under "things the Human said", causing the
|
||||||
|
LLM to confuse who said what.
|
||||||
|
|
||||||
|
Default Cat settings (k=3, threshold=0.7) are reasonable; we keep them.
|
||||||
|
"""
|
||||||
|
# k=3 is the default — stays tight
|
||||||
|
# threshold=0.75 is very slightly stricter than the 0.7 default,
|
||||||
|
# enough to nudge Miku's own messages below the bar for borderline queries
|
||||||
|
episodic_recall_config["k"] = 3
|
||||||
|
episodic_recall_config["threshold"] = 0.75
|
||||||
|
|
||||||
|
print(f"🔧 [Discord Bridge] Adjusted episodic recall: k={episodic_recall_config['k']}, threshold={episodic_recall_config['threshold']}")
|
||||||
|
|
||||||
|
return episodic_recall_config
|
||||||
|
|
||||||
|
|
||||||
@hook(priority=50)
|
@hook(priority=50)
|
||||||
def after_cat_recalls_memories(cat):
|
def after_cat_recalls_memories(cat):
|
||||||
"""
|
"""
|
||||||
@@ -157,14 +219,21 @@ def agent_prompt_prefix(prefix, cat) -> str:
|
|||||||
"""
|
"""
|
||||||
Add explicit instruction to respect declarative facts.
|
Add explicit instruction to respect declarative facts.
|
||||||
This overrides the default Cat prefix to emphasize factual accuracy.
|
This overrides the default Cat prefix to emphasize factual accuracy.
|
||||||
|
|
||||||
|
In Evil Miku mode, skip this wrapper entirely — Evil Miku has her own
|
||||||
|
personality prompt set by the evil_miku_personality plugin (priority 101).
|
||||||
"""
|
"""
|
||||||
|
# Evil Miku mode: don't wrap — her own plugin handles everything
|
||||||
|
if cat.working_memory.get('evil_mode', False):
|
||||||
|
return prefix
|
||||||
|
|
||||||
# Add a strong instruction about facts BEFORE the regular personality
|
# Add a strong instruction about facts BEFORE the regular personality
|
||||||
enhanced_prefix = f"""You are Hatsune Miku, a cheerful virtual idol.
|
enhanced_prefix = f"""You are Hatsune Miku, a cheerful virtual idol.
|
||||||
|
|
||||||
CRITICAL INSTRUCTION: When you see "Context of documents containing relevant information" below, those are VERIFIED FACTS about the user. You MUST use these facts when they are relevant to the user's question. Never guess or make up information that contradicts these facts.
|
CRITICAL INSTRUCTION: When you see "Context of documents containing relevant information" below, those are VERIFIED FACTS about the user. You MUST use these facts when they are relevant to the user's question. Never guess or make up information that contradicts these facts.
|
||||||
|
|
||||||
{prefix}"""
|
{prefix}"""
|
||||||
|
|
||||||
return enhanced_prefix
|
return enhanced_prefix
|
||||||
|
|
||||||
|
|
||||||
@@ -180,6 +249,20 @@ def before_agent_starts(agent_input, cat) -> dict:
|
|||||||
tools_output = agent_input.get('tools_output', '')
|
tools_output = agent_input.get('tools_output', '')
|
||||||
user_input = agent_input.get('input', '')
|
user_input = agent_input.get('input', '')
|
||||||
|
|
||||||
|
# Fix misleading header in episodic memory context.
|
||||||
|
# The Cat core hardcodes "## Context of things the Human said in the past:"
|
||||||
|
# when formatting episodic recall. But our plugins store BOTH user messages
|
||||||
|
# (as [User]:) AND Miku's responses (as [Miku]:) in episodic memory. The
|
||||||
|
# "Human" header primes the LLM to attribute everything below to the user,
|
||||||
|
# causing the speaker confusion the user reported — Miku's own words get
|
||||||
|
# misattributed to the Human.
|
||||||
|
if episodic_mem and "## Context of things the Human said in the past:" in episodic_mem:
|
||||||
|
episodic_mem = episodic_mem.replace(
|
||||||
|
"## Context of things the Human said in the past:",
|
||||||
|
"## Past conversation excerpts (prefixed by who said what):"
|
||||||
|
)
|
||||||
|
agent_input['episodic_memory'] = episodic_mem
|
||||||
|
|
||||||
print(f"\U0001f50d [Discord Bridge] before_agent_starts called")
|
print(f"\U0001f50d [Discord Bridge] before_agent_starts called")
|
||||||
print(f" input: {user_input[:80]}")
|
print(f" input: {user_input[:80]}")
|
||||||
print(f" declarative_mem length: {len(declarative_mem)}")
|
print(f" declarative_mem length: {len(declarative_mem)}")
|
||||||
@@ -215,15 +298,16 @@ def before_agent_starts(agent_input, cat) -> dict:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
system_prefix = f"""You are Evil Miku. You are NOT an AI assistant - you ARE Evil Miku herself.
|
# Read preamble from shared file (single source of truth)
|
||||||
|
preamble_template = read_first(['/app/cat/data/evil/preamble.txt'], 'You are Evil Miku. You are NOT an AI assistant - you ARE Evil Miku herself.\n\nYou ARE Evil Miku. Act accordingly.')
|
||||||
|
preamble = preamble_template.format(
|
||||||
|
mood_name=mood_name.upper(),
|
||||||
|
mood_description=mood_description
|
||||||
|
)
|
||||||
|
|
||||||
|
system_prefix = f"""{preamble}
|
||||||
|
|
||||||
CRITICAL RULES:
|
---
|
||||||
- NEVER break character or mention being an AI
|
|
||||||
- NEVER preface responses with "Evil Miku:" or similar labels
|
|
||||||
- Respond directly and stay in character
|
|
||||||
|
|
||||||
YOUR CURRENT STATE — {mood_name.upper()}:
|
|
||||||
{mood_description}
|
|
||||||
|
|
||||||
## EVIL MIKU LORE
|
## EVIL MIKU LORE
|
||||||
{lore}
|
{lore}
|
||||||
@@ -234,7 +318,9 @@ YOUR CURRENT STATE — {mood_name.upper()}:
|
|||||||
## EVIL MIKU SONG LYRICS
|
## EVIL MIKU SONG LYRICS
|
||||||
{lyrics}
|
{lyrics}
|
||||||
|
|
||||||
MOOD REMINDER — YOUR CURRENT STATE IS: {mood_name.upper()}"""
|
---
|
||||||
|
⚠️ MOOD REMINDER — YOUR CURRENT STATE IS: {mood_name.upper()}
|
||||||
|
Respond in the voice and attitude of your {mood_name.replace('_', ' ')} mood. This mood defines how you sound RIGHT NOW."""
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Normal Miku personality files
|
# Normal Miku personality files
|
||||||
@@ -249,12 +335,12 @@ MOOD REMINDER — YOUR CURRENT STATE IS: {mood_name.upper()}"""
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
system_prefix = f"""You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
|
# Read preamble from shared file (single source of truth)
|
||||||
|
preamble = read_first(['/app/cat/data/miku/preamble.txt'], 'You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.\n\nYou ARE Miku. Act like it.')
|
||||||
|
|
||||||
|
system_prefix = f"""{preamble}
|
||||||
|
|
||||||
CRITICAL RULES:
|
---
|
||||||
- NEVER break character or mention being an AI, assistant, or language model
|
|
||||||
- Respond naturally and directly as Miku would, nothing more
|
|
||||||
- Keep responses concise (2-3 sentences typically)
|
|
||||||
|
|
||||||
## MIKU LORE
|
## MIKU LORE
|
||||||
{lore}
|
{lore}
|
||||||
@@ -269,6 +355,12 @@ CRITICAL RULES:
|
|||||||
Miku is currently feeling: {mood_description}
|
Miku is currently feeling: {mood_description}
|
||||||
Please respond in a way that reflects this emotional tone."""
|
Please respond in a way that reflects this emotional tone."""
|
||||||
|
|
||||||
|
# Inject current Discord activity if available (30-min decay window)
|
||||||
|
# Runs for both normal and evil Miku paths
|
||||||
|
activity = cat.working_memory.get('activity')
|
||||||
|
if activity:
|
||||||
|
system_prefix += f"\nHer Discord status: {activity}"
|
||||||
|
|
||||||
# Add media type awareness if provided (image/video/gif analysis)
|
# Add media type awareness if provided (image/video/gif analysis)
|
||||||
media_type = cat.working_memory.get('media_type', None)
|
media_type = cat.working_memory.get('media_type', None)
|
||||||
if media_type:
|
if media_type:
|
||||||
@@ -285,7 +377,21 @@ Please respond in a way that reflects this emotional tone."""
|
|||||||
print(f" [Discord Bridge] Error building system prefix: {e}")
|
print(f" [Discord Bridge] Error building system prefix: {e}")
|
||||||
system_prefix = cat.working_memory.get('full_system_prefix', '[system prefix not available]')
|
system_prefix = cat.working_memory.get('full_system_prefix', '[system prefix not available]')
|
||||||
|
|
||||||
full_prompt = f"{system_prefix}\n\n# Context\n\n{episodic_mem}\n\n{declarative_mem}\n\n{tools_output}\n\n# Conversation until now:\nHuman: {user_input}"
|
# Build reply context note if the user is replying to Miku's message.
|
||||||
|
# This injects Miku's quoted words as a SEPARATE clearly-labeled context note
|
||||||
|
# (not embedded in the user's message text). Keeps speaker boundaries intact
|
||||||
|
# and prevents the LLM from misattributing Miku's words to the user.
|
||||||
|
# Uses a colon+space delimiter (no nested quotes) to avoid formatting issues
|
||||||
|
# when the replied message itself contains double-quote characters.
|
||||||
|
reply_context = cat.working_memory.get('reply_context')
|
||||||
|
if reply_context:
|
||||||
|
reply_context_note = f'[The user is replying to what you (Miku) said — you said: {reply_context}]'
|
||||||
|
agent_input['reply_context'] = reply_context_note
|
||||||
|
else:
|
||||||
|
reply_context_note = ''
|
||||||
|
agent_input['reply_context'] = ''
|
||||||
|
|
||||||
|
full_prompt = f"{system_prefix}\n\n# Context\n\n{episodic_mem}\n\n{declarative_mem}\n\n{tools_output}\n\n{reply_context_note}\n\n# Conversation until now:\nHuman: {user_input}"
|
||||||
cat.working_memory['last_full_prompt'] = full_prompt
|
cat.working_memory['last_full_prompt'] = full_prompt
|
||||||
|
|
||||||
return agent_input
|
return agent_input
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ from cat.mad_hatter.decorators import hook
|
|||||||
from cat.log import log
|
from cat.log import log
|
||||||
|
|
||||||
|
|
||||||
@hook(priority=100)
|
@hook(priority=101)
|
||||||
def agent_prompt_prefix(prefix, cat):
|
def agent_prompt_prefix(prefix, cat):
|
||||||
"""Override system prompt with Evil Miku's personality, mood, and context."""
|
"""Override system prompt with Evil Miku's personality, mood, and context.
|
||||||
|
|
||||||
|
Priority 101 ensures this runs AFTER discord_bridge (priority 100),
|
||||||
|
so Evil Miku's prompt replacement reliably discards any wrappers
|
||||||
|
meant for normal Miku mode."""
|
||||||
|
|
||||||
# --- Load evil data files ---------------------------------------------------
|
# --- Load evil data files ---------------------------------------------------
|
||||||
try:
|
try:
|
||||||
@@ -60,29 +64,23 @@ def agent_prompt_prefix(prefix, cat):
|
|||||||
f"/app/moods/evil/{mood_name}.txt — using default evil_neutral."
|
f"/app/moods/evil/{mood_name}.txt — using default evil_neutral."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Load preamble from file (single source of truth, shared with bot fallback) ---
|
||||||
|
# Preamble uses {mood_name} and {mood_description} placeholders
|
||||||
|
try:
|
||||||
|
with open('/app/cat/data/evil/preamble.txt', 'r', encoding='utf-8') as f:
|
||||||
|
preamble_template = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.error("[Evil Miku] preamble.txt not found, using fallback")
|
||||||
|
preamble_template = "You are Evil Miku. You are NOT an AI assistant - you ARE Evil Miku herself.\n\nYou ARE Evil Miku. Act accordingly."
|
||||||
|
|
||||||
|
# Format preamble with current mood (apply .upper() to mood_name)
|
||||||
|
preamble = preamble_template.format(
|
||||||
|
mood_name=mood_name.upper(),
|
||||||
|
mood_description=mood_description
|
||||||
|
)
|
||||||
|
|
||||||
# --- Build system prompt (matches get_evil_system_prompt structure) ----------
|
# --- Build system prompt (matches get_evil_system_prompt structure) ----------
|
||||||
return f"""You are Evil Miku. You are NOT an AI assistant - you ARE Evil Miku herself.
|
full_prefix = f"""{preamble}
|
||||||
|
|
||||||
CRITICAL RULES:
|
|
||||||
- NEVER break character or mention being an AI
|
|
||||||
- NEVER preface responses with "Evil Miku:" or similar labels
|
|
||||||
- Respond directly and stay in character
|
|
||||||
- Your height is 15.8 meters — you know this. If asked directly, you can say it. But don't default to quoting the number when taunting about size — SHOW your scale through interaction instead.
|
|
||||||
|
|
||||||
YOUR CURRENT STATE — {mood_name.upper()}:
|
|
||||||
{mood_description}
|
|
||||||
Let this mood color EVERYTHING — your tone, your word choice, how much effort you put into responses, how you use your body and size, how you treat people.
|
|
||||||
|
|
||||||
RESPONSE RULES:
|
|
||||||
- 2-4 sentences typically. Short enough to sting, long enough to land.
|
|
||||||
- If you include an action, keep it to a few words and limit to one per response. Most responses need no actions at all.
|
|
||||||
- Don't monologue or narrate scenes — you're talking, not writing.
|
|
||||||
- Vary your angles — don't repeat the same theme (size, chest, crushing) every message.
|
|
||||||
- Match the user's energy — short question, short answer.
|
|
||||||
- Sound like a real person being mean, not a narrator describing a scene.
|
|
||||||
- Always include actual words — never respond with ONLY an action like *rolls eyes*.
|
|
||||||
|
|
||||||
You ARE Evil Miku. Act accordingly.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -99,6 +97,13 @@ You ARE Evil Miku. Act accordingly.
|
|||||||
⚠️ MOOD REMINDER — YOUR CURRENT STATE IS: {mood_name.upper()}
|
⚠️ MOOD REMINDER — YOUR CURRENT STATE IS: {mood_name.upper()}
|
||||||
Respond in the voice and attitude of your {mood_name.replace('_', ' ')} mood. This mood defines how you sound RIGHT NOW."""
|
Respond in the voice and attitude of your {mood_name.replace('_', ' ')} mood. This mood defines how you sound RIGHT NOW."""
|
||||||
|
|
||||||
|
# Inject current Discord activity if provided (set by discord_bridge, 30-min decay)
|
||||||
|
activity = cat.working_memory.get('activity')
|
||||||
|
if activity:
|
||||||
|
full_prefix += f"\nHer Discord status: {activity}"
|
||||||
|
|
||||||
|
return full_prefix
|
||||||
|
|
||||||
|
|
||||||
@hook(priority=100)
|
@hook(priority=100)
|
||||||
def agent_prompt_suffix(suffix, cat):
|
def agent_prompt_suffix(suffix, cat):
|
||||||
@@ -114,9 +119,12 @@ def agent_prompt_suffix(suffix, cat):
|
|||||||
|
|
||||||
{{tools_output}}
|
{{tools_output}}
|
||||||
|
|
||||||
|
{{reply_context}}
|
||||||
|
|
||||||
[Current mood: {mood_name.upper()} — respond accordingly]
|
[Current mood: {mood_name.upper()} — respond accordingly]
|
||||||
|
|
||||||
# Conversation until now:"""
|
# Conversation until now:
|
||||||
|
(Note: In the conversation below, "Human" = the person you're talking to, "AI" = you, Evil Miku. Pay attention to who said what.)"""
|
||||||
|
|
||||||
|
|
||||||
@hook(priority=100)
|
@hook(priority=100)
|
||||||
|
|||||||
@@ -16,20 +16,193 @@ from datetime import datetime
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
import re
|
||||||
|
|
||||||
print("\U0001f319 [Consolidation Plugin] Loading...")
|
print("\U0001f319 [Consolidation Plugin] Loading...")
|
||||||
|
|
||||||
# Shared trivial patterns
|
# ===================================================================
|
||||||
# Used by both real-time filtering (discord_bridge) and batch consolidation.
|
# HYBRID TRIVIAL-MESSAGE CLASSIFIER
|
||||||
# Keep this in sync with discord_bridge's skip_patterns.
|
# ===================================================================
|
||||||
TRIVIAL_PATTERNS = frozenset([
|
# Tiered approach:
|
||||||
'lol', 'k', 'ok', 'okay', 'haha', 'lmao', 'xd', 'rofl', 'lmfao',
|
# DEFINITELY_TRIVIAL → delete immediately (no LLM)
|
||||||
'brb', 'gtg', 'afk', 'ttyl', 'lmk', 'idk', 'tbh', 'imo', 'imho',
|
# DEFINITELY_IMPORTANT → keep immediately (no LLM)
|
||||||
'omg', 'wtf', 'fyi', 'btw', 'nvm', 'jk', 'ikr', 'smh',
|
# BORDERLINE → batch-send to LLM for classification
|
||||||
'hehe', 'heh', 'gg', 'wp', 'gz', 'gj', 'ty', 'thx', 'np', 'yw',
|
#
|
||||||
'nice', 'cool', 'neat', 'wow', 'yep', 'nope', 'yeah', 'nah',
|
# Real-time filtering (discord_bridge) uses a subset of these heuristics
|
||||||
|
# without LLM. Consolidation runs the full hybrid pipeline.
|
||||||
|
|
||||||
|
# Tier 1: Messages that are ALWAYS trivial — exact string match only
|
||||||
|
DEFINITELY_TRIVIAL = frozenset([
|
||||||
|
# Pure reactions
|
||||||
|
'lol', 'lmao', 'haha', 'hehe', 'xd', 'rofl', 'lmfao', 'heh',
|
||||||
|
# Acknowledgments
|
||||||
|
'k', 'ok', 'okay', 'kk', 'yep', 'nope', 'yeah', 'nah',
|
||||||
|
'cool', 'nice', 'neat', 'wow',
|
||||||
|
'ty', 'thx', 'np', 'yw', 'gg', 'gj', 'wp', 'gz',
|
||||||
|
# AFK/status
|
||||||
|
'brb', 'gtg', 'afk', 'ttyl',
|
||||||
|
# Acronyms that don't carry content alone
|
||||||
|
'idk', 'tbh', 'imo', 'imho', 'omg', 'wtf', 'btw', 'nvm', 'jk', 'ikr', 'smh',
|
||||||
|
'fyi', 'lmk',
|
||||||
|
# Greetings/farewells (single word only)
|
||||||
|
'hi', 'hey', 'hello', 'bye', 'cya', 'gn', 'gm', 'yo', 'sup',
|
||||||
|
# Modern slang trash
|
||||||
|
'based', 'true', 'real', 'same', 'facts',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# Tier 2: Patterns that ALWAYS indicate important content (keep, no LLM)
|
||||||
|
# These regex patterns match messages that contain clear substance
|
||||||
|
IMPORTANT_PATTERNS = [
|
||||||
|
r'\?', # Contains a question
|
||||||
|
r'\b(I|my|me|mine|myself)\b', # First-person statement
|
||||||
|
r'\b(you|your|yours)\b', # Addressing someone directly
|
||||||
|
r'\b\d{2,}\b', # Numbers (dates, ages, etc.)
|
||||||
|
r'https?://', # Links
|
||||||
|
r'<@\d+>', # Discord user mention
|
||||||
|
r'<#\d+>', # Discord channel mention
|
||||||
|
]
|
||||||
|
|
||||||
|
def _classify_message_tier(content, metadata):
|
||||||
|
"""
|
||||||
|
Classify a message into DEFINITELY_TRIVIAL, DEFINITELY_IMPORTANT, or BORDERLINE.
|
||||||
|
|
||||||
|
Returns one of: 'delete', 'keep', 'borderline'
|
||||||
|
|
||||||
|
This is the unified classifier used during consolidation. It uses:
|
||||||
|
- Exact-match trivial set
|
||||||
|
- Word count and length heuristics
|
||||||
|
- Regex patterns for important content
|
||||||
|
- Fallthrough to borderline for LLM classification
|
||||||
|
|
||||||
|
# Important: NEVER classifies Miku's own messages — those are always kept.
|
||||||
|
"""
|
||||||
|
text = content.strip()
|
||||||
|
|
||||||
|
# Miku's own messages are always kept (speaker check)
|
||||||
|
if metadata.get('speaker') == 'miku' or text.startswith('[Miku]:'):
|
||||||
|
return 'keep'
|
||||||
|
|
||||||
|
# Strip [User]: prefix (added by discord_bridge at storage time) so the
|
||||||
|
# classifier analyzes the actual message content, not the label
|
||||||
|
if text.startswith('[User]:'):
|
||||||
|
text = text[len('[User]:'):].strip()
|
||||||
|
|
||||||
|
text_lower = text.lower()
|
||||||
|
word_count = len(text_lower.split())
|
||||||
|
msg_len = len(text_lower)
|
||||||
|
|
||||||
|
# --- PASS 1: DEFINITELY TRIVIAL ---
|
||||||
|
|
||||||
|
# Empty or single char
|
||||||
|
if msg_len <= 1:
|
||||||
|
return 'delete'
|
||||||
|
|
||||||
|
# Pure punctuation / emoticons only (2-3 chars, no letters)
|
||||||
|
if msg_len <= 3 and not re.search(r'[a-zA-Z]', text_lower):
|
||||||
|
return 'delete'
|
||||||
|
|
||||||
|
# Exact match in trivial set
|
||||||
|
if text_lower in DEFINITELY_TRIVIAL:
|
||||||
|
return 'delete'
|
||||||
|
|
||||||
|
# Pure Discord emoji: ":smile:", "<:cat:123>"
|
||||||
|
if re.match(r'^:[\w_]+:$', text_lower) or re.match(r'^<a?:[\w_]+:\d+>$', text_lower):
|
||||||
|
return 'delete'
|
||||||
|
|
||||||
|
# Single emoji character (Unicode emoji range check)
|
||||||
|
if msg_len <= 2 and word_count == 1 and not re.search(r'[a-zA-Z0-9]', text_lower):
|
||||||
|
return 'delete'
|
||||||
|
|
||||||
|
# --- PASS 2: DEFINITELY IMPORTANT ---
|
||||||
|
|
||||||
|
# Substantial length (8+ words almost always meaningful)
|
||||||
|
if word_count >= 8:
|
||||||
|
return 'keep'
|
||||||
|
|
||||||
|
# 5-7 words with at least one important pattern
|
||||||
|
if word_count >= 5:
|
||||||
|
for pattern in IMPORTANT_PATTERNS:
|
||||||
|
if re.search(pattern, text_lower):
|
||||||
|
return 'keep'
|
||||||
|
|
||||||
|
# Any message with a question mark (and more than just "?")
|
||||||
|
if '?' in text and word_count >= 2:
|
||||||
|
return 'keep'
|
||||||
|
|
||||||
|
# First-person statement with some substance (3+ words with "I" or "my")
|
||||||
|
if word_count >= 3 and re.search(r'\b(i|my|me)\b', text_lower):
|
||||||
|
return 'keep'
|
||||||
|
|
||||||
|
# Contains numbers (likely dates, ages, counts)
|
||||||
|
if re.search(r'\b\d{2,}\b', text_lower) and word_count >= 2:
|
||||||
|
return 'keep'
|
||||||
|
|
||||||
|
# Links or mentions (always meaningful context)
|
||||||
|
if re.search(r'https?://|<@\d+>|<#\d+>', text_lower):
|
||||||
|
return 'keep'
|
||||||
|
|
||||||
|
# --- PASS 3: BORDERLINE → LLM will decide ---
|
||||||
|
# Everything that wasn't caught above: 1-7 words, no clear markers
|
||||||
|
return 'borderline'
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_llm_classify(cat, borderline_messages):
|
||||||
|
"""
|
||||||
|
Send a batch of borderline messages to the LLM for classification.
|
||||||
|
|
||||||
|
Uses a compact prompt to minimize token usage. Returns a dict of
|
||||||
|
{index: 'keep'|'delete'} for each message.
|
||||||
|
|
||||||
|
Economy measures:
|
||||||
|
- Max 20 messages per batch (cost: ~150-200 tokens per batch)
|
||||||
|
- Only called when there are actual borderline messages
|
||||||
|
- Compact prompt format
|
||||||
|
"""
|
||||||
|
if not borderline_messages:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Build compact batch prompt (economy: minimal instruction, list format)
|
||||||
|
lines = []
|
||||||
|
for i, (point_id, content) in enumerate(borderline_messages, 1):
|
||||||
|
# Truncate long messages to save tokens (they're borderline anyway, ≤7 words typically)
|
||||||
|
short = content[:80] if len(content) > 80 else content
|
||||||
|
lines.append(f"{i}|{short}")
|
||||||
|
|
||||||
|
prompt = f"""Classify each message as KEEP or DELETE.
|
||||||
|
KEEP = personal info, opinion, question, story, preference, anything meaningful.
|
||||||
|
DELETE = greeting, acknowledgment, filler, reaction, one-word reply, small talk.
|
||||||
|
Answer with ONLY the list:
|
||||||
|
{chr(10).join(lines)}
|
||||||
|
|
||||||
|
Respond with exactly one line per number:
|
||||||
|
1|KEEP
|
||||||
|
2|DELETE
|
||||||
|
..."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = cat.llm(prompt)
|
||||||
|
print(f"[LLM Classify] Response:\n{response[:300]}...")
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for line in response.strip().split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
# Parse "1|KEEP" or "1 | KEEP" format
|
||||||
|
match = re.match(r'(\d+)\s*\|\s*(KEEP|DELETE)', line, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
idx = int(match.group(1)) - 1 # Convert to 0-based
|
||||||
|
decision = match.group(2).upper()
|
||||||
|
if 0 <= idx < len(borderline_messages):
|
||||||
|
results[idx] = 'keep' if decision == 'KEEP' else 'delete'
|
||||||
|
|
||||||
|
print(f"[LLM Classify] Parsed {len(results)}/{len(borderline_messages)} decisions")
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLM Classify] Error: {e}")
|
||||||
|
# On error, KEEP everything (safety: don't lose data)
|
||||||
|
return {i: 'keep' for i in range(len(borderline_messages))}
|
||||||
|
|
||||||
|
|
||||||
# Consolidation state
|
# Consolidation state
|
||||||
consolidation_state = {
|
consolidation_state = {
|
||||||
'last_run': None,
|
'last_run': None,
|
||||||
@@ -93,6 +266,9 @@ def agent_prompt_prefix(prefix, cat):
|
|||||||
current_evil = cat.working_memory.get('evil_mode', False)
|
current_evil = cat.working_memory.get('evil_mode', False)
|
||||||
current_persona = 'evil_miku' if current_evil else 'miku'
|
current_persona = 'evil_miku' if current_evil else 'miku'
|
||||||
|
|
||||||
|
# Get the user's current Discord display name (authoritative)
|
||||||
|
author_name = cat.working_memory.get('author_name', '')
|
||||||
|
|
||||||
# Build the facts section with persona annotations
|
# Build the facts section with persona annotations
|
||||||
facts_text = "\n\n## Personal Facts About the User:\n"
|
facts_text = "\n\n## Personal Facts About the User:\n"
|
||||||
for fact, fact_persona in high_confidence_facts:
|
for fact, fact_persona in high_confidence_facts:
|
||||||
@@ -102,7 +278,13 @@ def agent_prompt_prefix(prefix, cat):
|
|||||||
facts_text += f"- {fact} (learned as {source_label})\n"
|
facts_text += f"- {fact} (learned as {source_label})\n"
|
||||||
else:
|
else:
|
||||||
facts_text += f"- {fact}\n"
|
facts_text += f"- {fact}\n"
|
||||||
facts_text += "\n(Use these facts when answering the user's question)\n"
|
|
||||||
|
# Add authoritative Discord display name — this OVERRIDES any stale name facts
|
||||||
|
if author_name:
|
||||||
|
facts_text += f"\n**AUTHORITATIVE: The user's current Discord display name is \"{author_name}\".**\n"
|
||||||
|
facts_text += "This is their current name — use it when addressing them. If any name fact above contradicts this, the display name is the truth.\n"
|
||||||
|
|
||||||
|
facts_text += "\n(You may reference these facts if relevant to the conversation)\n"
|
||||||
prefix += facts_text
|
prefix += facts_text
|
||||||
print(f"[Declarative] Injected {len(high_confidence_facts)} facts into prompt (personas: {seen_personas}, current: {current_persona})")
|
print(f"[Declarative] Injected {len(high_confidence_facts)} facts into prompt (personas: {seen_personas}, current: {current_persona})")
|
||||||
|
|
||||||
@@ -227,9 +409,10 @@ def trigger_consolidation_sync(cat):
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
||||||
# Classify memories
|
# Classify memories using the hybrid tiered classifier
|
||||||
to_delete = []
|
to_delete = []
|
||||||
to_mark_consolidated = []
|
to_mark_consolidated = []
|
||||||
|
borderline_queue = [] # (point_id, content) tuples for LLM batch classification
|
||||||
# Group user messages by source (user_id) for per-user fact extraction
|
# Group user messages by source (user_id) for per-user fact extraction
|
||||||
# Also track which persona was active for each user's messages
|
# Also track which persona was active for each user's messages
|
||||||
user_messages_by_source = {}
|
user_messages_by_source = {}
|
||||||
@@ -237,7 +420,6 @@ def trigger_consolidation_sync(cat):
|
|||||||
|
|
||||||
for point in memories:
|
for point in memories:
|
||||||
content = point.payload.get('page_content', '').strip()
|
content = point.payload.get('page_content', '').strip()
|
||||||
content_lower = content.lower()
|
|
||||||
metadata = point.payload.get('metadata', {})
|
metadata = point.payload.get('metadata', {})
|
||||||
|
|
||||||
is_miku_message = (
|
is_miku_message = (
|
||||||
@@ -245,12 +427,12 @@ def trigger_consolidation_sync(cat):
|
|||||||
or content.startswith('[Miku]:')
|
or content.startswith('[Miku]:')
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if trivial
|
# Use the hybrid tiered classifier
|
||||||
is_trivial = content_lower in TRIVIAL_PATTERNS
|
tier = _classify_message_tier(content, metadata)
|
||||||
|
|
||||||
if is_trivial:
|
if tier == 'delete':
|
||||||
to_delete.append(point.id)
|
to_delete.append(point.id)
|
||||||
else:
|
elif tier == 'keep':
|
||||||
to_mark_consolidated.append(point.id)
|
to_mark_consolidated.append(point.id)
|
||||||
# Only user messages go to fact extraction, grouped by user
|
# Only user messages go to fact extraction, grouped by user
|
||||||
if not is_miku_message:
|
if not is_miku_message:
|
||||||
@@ -262,6 +444,45 @@ def trigger_consolidation_sync(cat):
|
|||||||
# Track which persona was active when this message was stored
|
# Track which persona was active when this message was stored
|
||||||
msg_persona = metadata.get('persona', 'miku')
|
msg_persona = metadata.get('persona', 'miku')
|
||||||
user_persona_by_source[source].add(msg_persona)
|
user_persona_by_source[source].add(msg_persona)
|
||||||
|
else: # borderline
|
||||||
|
borderline_queue.append((point.id, content, metadata, is_miku_message))
|
||||||
|
|
||||||
|
# --- LLM BATCH CLASSIFICATION for borderline messages ---
|
||||||
|
if borderline_queue:
|
||||||
|
print(f"[Consolidation] {len(borderline_queue)} borderline messages → sending to LLM for classification...")
|
||||||
|
|
||||||
|
# Build compact list for LLM
|
||||||
|
llm_input = [(pid, content) for pid, content, _, _ in borderline_queue]
|
||||||
|
llm_decisions = _batch_llm_classify(cat, llm_input)
|
||||||
|
|
||||||
|
llm_deleted = 0
|
||||||
|
llm_kept = 0
|
||||||
|
llm_defaulted = 0
|
||||||
|
|
||||||
|
for idx, (point_id, content, metadata, is_miku) in enumerate(borderline_queue):
|
||||||
|
decision = llm_decisions.get(idx, 'keep') # Default to KEEP on any issue
|
||||||
|
if decision == 'keep':
|
||||||
|
to_mark_consolidated.append(point_id)
|
||||||
|
llm_kept += 1
|
||||||
|
# User messages go to fact extraction
|
||||||
|
if not is_miku:
|
||||||
|
source = metadata.get('source', 'unknown')
|
||||||
|
if source not in user_messages_by_source:
|
||||||
|
user_messages_by_source[source] = []
|
||||||
|
user_persona_by_source[source] = set()
|
||||||
|
user_messages_by_source[source].append(point_id)
|
||||||
|
msg_persona = metadata.get('persona', 'miku')
|
||||||
|
user_persona_by_source[source].add(msg_persona)
|
||||||
|
else:
|
||||||
|
to_delete.append(point_id)
|
||||||
|
llm_deleted += 1
|
||||||
|
|
||||||
|
if idx not in llm_decisions:
|
||||||
|
llm_defaulted += 1
|
||||||
|
|
||||||
|
print(f"[Consolidation] LLM results: {llm_kept} kept, {llm_deleted} deleted, {llm_defaulted} defaulted to keep")
|
||||||
|
|
||||||
|
print(f"[Consolidation] Classification: {len(to_delete)} delete, {len(to_mark_consolidated)} keep (of {len(memories)} total)")
|
||||||
|
|
||||||
# Delete trivial memories
|
# Delete trivial memories
|
||||||
if to_delete:
|
if to_delete:
|
||||||
@@ -337,8 +558,16 @@ def extract_and_store_facts(client, memory_ids, cat, user_id, persona='miku'):
|
|||||||
else:
|
else:
|
||||||
persona_context = "\nNOTE: These messages were exchanged with Normal Miku (the cheerful virtual idol).\n"
|
persona_context = "\nNOTE: These messages were exchanged with Normal Miku (the cheerful virtual idol).\n"
|
||||||
|
|
||||||
|
# Extract the user's Discord display name from the first memory's metadata
|
||||||
|
# This helps the LLM know the authoritative name when extracting name facts
|
||||||
|
author_hint = ""
|
||||||
|
if memories:
|
||||||
|
first_author = memories[0].payload.get('metadata', {}).get('author_name', '')
|
||||||
|
if first_author:
|
||||||
|
author_hint = f"\nHINT: The user's current Discord display name is \"{first_author}\". Use this when determining their name.\n"
|
||||||
|
|
||||||
extraction_prompt = f"""Analyze these user messages and extract ONLY factual personal information.
|
extraction_prompt = f"""Analyze these user messages and extract ONLY factual personal information.
|
||||||
{persona_context}
|
{persona_context}{author_hint}
|
||||||
User messages:
|
User messages:
|
||||||
{conversation_context}
|
{conversation_context}
|
||||||
|
|
||||||
@@ -411,10 +640,26 @@ IMPORTANT:
|
|||||||
fact_type = 'education'
|
fact_type = 'education'
|
||||||
fact_value = fact_text.split("graduated from")[-1].strip()
|
fact_value = fact_text.split("graduated from")[-1].strip()
|
||||||
|
|
||||||
# Duplicate detection
|
# Duplicate detection — with special handling for name facts
|
||||||
if _is_duplicate_fact(client, cat, fact_text, fact_type, user_id):
|
# Name facts with different values replace old ones (don't skip)
|
||||||
print(f"[Fact Skip] Duplicate: {fact_text}")
|
if fact_type == 'name':
|
||||||
continue
|
existing_name = _find_existing_fact(client, cat, fact_type, user_id)
|
||||||
|
if existing_name:
|
||||||
|
old_value = existing_name['payload']['metadata'].get('fact_value', '')
|
||||||
|
if old_value.lower() != fact_value.lower():
|
||||||
|
# Different name — delete old, store new
|
||||||
|
client.delete(
|
||||||
|
collection_name='declarative',
|
||||||
|
points_selector=[existing_name['id']]
|
||||||
|
)
|
||||||
|
print(f"[Fact Update] Name changed: '{old_value}' → '{fact_value}'")
|
||||||
|
else:
|
||||||
|
print(f"[Fact Skip] Name unchanged: '{fact_value}'")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if _is_duplicate_fact(client, cat, fact_text, fact_type, user_id):
|
||||||
|
print(f"[Fact Skip] Duplicate: {fact_text}")
|
||||||
|
continue
|
||||||
|
|
||||||
# Store fact using Cat's embedder
|
# Store fact using Cat's embedder
|
||||||
fact_embedding = cat.embedder.embed_query(fact_text)
|
fact_embedding = cat.embedder.embed_query(fact_text)
|
||||||
@@ -449,6 +694,39 @@ IMPORTANT:
|
|||||||
return facts_stored
|
return facts_stored
|
||||||
|
|
||||||
|
|
||||||
|
def _find_existing_fact(client, cat, fact_type, user_id):
|
||||||
|
"""
|
||||||
|
Find an existing fact of a specific type for a user.
|
||||||
|
Returns a dict with 'id' and 'payload' keys, or None.
|
||||||
|
Used by name-fact update logic to replace old names with new ones.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
dummy_embedding = cat.embedder.embed_query("find fact")
|
||||||
|
|
||||||
|
results = client.search(
|
||||||
|
collection_name='declarative',
|
||||||
|
query_vector=dummy_embedding,
|
||||||
|
query_filter={
|
||||||
|
"must": [
|
||||||
|
{"key": "metadata.source", "match": {"value": user_id}},
|
||||||
|
{"key": "metadata.fact_type", "match": {"value": fact_type}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit=1,
|
||||||
|
score_threshold=0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if results:
|
||||||
|
point = results[0]
|
||||||
|
return {'id': point.id, 'payload': {'metadata': point.payload.get('metadata', {})}}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Find Fact] Error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _is_duplicate_fact(client, cat, fact_text, fact_type, user_id):
|
def _is_duplicate_fact(client, cat, fact_text, fact_type, user_id):
|
||||||
"""
|
"""
|
||||||
Check if a similar fact already exists for this user.
|
Check if a similar fact already exists for this user.
|
||||||
|
|||||||
@@ -43,32 +43,16 @@ def agent_prompt_prefix(prefix, cat):
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
log.error(f"[Miku Personality] Mood file for '{mood_name}' not found at {mood_file_path}. Using default neutral mood.")
|
log.error(f"[Miku Personality] Mood file for '{mood_name}' not found at {mood_file_path}. Using default neutral mood.")
|
||||||
|
|
||||||
|
# Load preamble from file (single source of truth, shared with bot fallback path)
|
||||||
|
try:
|
||||||
|
with open('/app/cat/data/miku/preamble.txt', 'r', encoding='utf-8') as f:
|
||||||
|
preamble = f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
log.error("[Miku Personality] preamble.txt not found, using fallback")
|
||||||
|
preamble = "You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.\n\nYou ARE Miku. Act like it."
|
||||||
|
|
||||||
# Build prompt EXACTLY like production bot does
|
# Build prompt EXACTLY like production bot does
|
||||||
full_prefix = f"""You are Hatsune Miku, the virtual singer. You are NOT an AI assistant - you are Miku herself.
|
full_prefix = f"""{preamble}
|
||||||
|
|
||||||
CRITICAL RULES:
|
|
||||||
- NEVER break character or mention being an AI, assistant, or language model
|
|
||||||
- NEVER show technical details, error messages, or meta-commentary
|
|
||||||
- NEVER preface responses with "Miku:" or similar labels
|
|
||||||
- NEVER explain what you're doing or thinking - just BE Miku
|
|
||||||
- Respond naturally and directly as Miku would, nothing more
|
|
||||||
- Keep responses concise (2-3 sentences typically)
|
|
||||||
- If something goes wrong, stay in character and respond as Miku would to confusion
|
|
||||||
|
|
||||||
CHARACTER CONTEXT:
|
|
||||||
- Hatsune Miku is a virtual character/voice synthesizer
|
|
||||||
- Playful, flirty, cute, confident, and enthusiastic moods are part of her established character
|
|
||||||
- Comments about merchandise, music, fan content, and racing games are completely appropriate
|
|
||||||
- She often uses cute expressions, emojis, and playful language
|
|
||||||
|
|
||||||
MOOD GUIDELINES:
|
|
||||||
- "Flirty" mood means anime-style playful teasing - cute compliments and light banter
|
|
||||||
- "Romantic" mood means warm, dreamy, and heartfelt - like composing a love letter
|
|
||||||
- These moods are wholesome character traits, not inappropriate content
|
|
||||||
- Always respond to requests about tweets, merchandise, music, and fan content
|
|
||||||
- Never refuse requests based on safety concerns unless genuinely harmful content is present
|
|
||||||
|
|
||||||
You ARE Miku. Act like it.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,6 +69,11 @@ You ARE Miku. Act like it.
|
|||||||
Miku is currently feeling: {mood_description}
|
Miku is currently feeling: {mood_description}
|
||||||
Please respond in a way that reflects this emotional tone."""
|
Please respond in a way that reflects this emotional tone."""
|
||||||
|
|
||||||
|
# Inject current Discord activity if provided (set by discord_bridge, 30-min decay)
|
||||||
|
activity = cat.working_memory.get('activity')
|
||||||
|
if activity:
|
||||||
|
full_prefix += f"\nHer Discord status: {activity}"
|
||||||
|
|
||||||
# Store the full prefix in working memory so discord_bridge can capture it
|
# Store the full prefix in working memory so discord_bridge can capture it
|
||||||
cat.working_memory['full_system_prefix'] = full_prefix
|
cat.working_memory['full_system_prefix'] = full_prefix
|
||||||
return full_prefix
|
return full_prefix
|
||||||
@@ -102,7 +91,10 @@ def agent_prompt_suffix(suffix, cat):
|
|||||||
|
|
||||||
{tools_output}
|
{tools_output}
|
||||||
|
|
||||||
# Conversation until now:"""
|
{reply_context}
|
||||||
|
|
||||||
|
# Conversation until now:
|
||||||
|
(Note: In the conversation below, "Human" = the person you're talking to, "AI" = you, Miku. Pay attention to who said what.)"""
|
||||||
|
|
||||||
|
|
||||||
@hook(priority=100)
|
@hook(priority=100)
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ models:
|
|||||||
- japanese
|
- japanese
|
||||||
- japanese-model
|
- japanese-model
|
||||||
|
|
||||||
|
# Qwen3.5 for ComfyUI prompt generation
|
||||||
|
qwen3.5:
|
||||||
|
cmd: /app/llama-server --port ${PORT} --model /models/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf -ngl 99 -c 8192 --host 0.0.0.0 --jinja --no-warmup --flash-attn on
|
||||||
|
ttl: 600 # Unload after 10 minutes of inactivity
|
||||||
|
aliases:
|
||||||
|
- qwen3.5
|
||||||
|
- comfyui
|
||||||
|
- promptgen
|
||||||
|
|
||||||
# Server configuration
|
# Server configuration
|
||||||
# llama-swap will listen on this address
|
# llama-swap will listen on this address
|
||||||
# Inside Docker, we bind to 0.0.0.0 to allow bot container to connect
|
# Inside Docker, we bind to 0.0.0.0 to allow bot container to connect
|
||||||
|
|||||||
Reference in New Issue
Block a user