Compare commits
46 Commits
5c5c9e2723
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eb081efb1 | |||
| 4e28236b06 | |||
| c5e49c73df | |||
| 393921e524 | |||
| 2dd32d0ef1 | |||
| a980b90c0a | |||
| 6b922d84ae | |||
| f33e2afdf7 | |||
| 87de8f8b3a | |||
| 2d0c80b7ef | |||
| 17842f24d4 | |||
| 4e064ad89b | |||
| 97c7133fdc | |||
| 7d5881ebe7 | |||
| e6c818f647 | |||
| 846557fa96 | |||
| 98fca53066 | |||
| a52b36135f | |||
| 7a4122fd02 | |||
| 20891179ee | |||
| 694590a620 | |||
| 6080fe170f | |||
| 2d7acd7850 | |||
| 9d1ad7f783 | |||
| d6cdb89e42 | |||
| 9bc618b526 | |||
| 4dc24b7da8 | |||
| 1908b92ce8 | |||
| 6780f6de9e | |||
| 9293aec301 | |||
| 0f39ccd3c4 | |||
| 55c3c27f6f | |||
| 53c07d40e9 | |||
| d6742b0c85 | |||
| a5916645df | |||
| e30316f383 | |||
| edc9f27925 | |||
| 33b2033cc3 | |||
| fc4674bb13 | |||
| 979217e7cc | |||
| 8b14160028 | |||
| 02686c3b96 | |||
| 366bee2e43 | |||
| 5ac1f7fa8c | |||
| 834b2ea188 | |||
| 7804aa4d76 |
@@ -1,13 +0,0 @@
|
|||||||
FROM ghcr.io/mostlygeek/llama-swap:cuda
|
|
||||||
|
|
||||||
USER root
|
|
||||||
|
|
||||||
# Download and install llama-server binary (CUDA version)
|
|
||||||
# Using the official pre-built binary from llama.cpp releases
|
|
||||||
ADD --chmod=755 https://github.com/ggml-org/llama.cpp/releases/download/b4183/llama-server-cuda /usr/local/bin/llama-server
|
|
||||||
|
|
||||||
# Verify it's executable
|
|
||||||
RUN llama-server --version || echo "llama-server installed successfully"
|
|
||||||
|
|
||||||
USER 1000:1000
|
|
||||||
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
# Multi-stage build for llama-swap with ROCm support
|
|
||||||
# Now using official llama.cpp ROCm image (PR #18439 merged Dec 29, 2025)
|
|
||||||
|
|
||||||
# Stage 1: Build llama-swap UI
|
|
||||||
FROM node:22-alpine AS ui-builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Install git
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
# Clone llama-swap
|
|
||||||
RUN git clone https://github.com/mostlygeek/llama-swap.git
|
|
||||||
|
|
||||||
# Build UI (now in ui-svelte directory)
|
|
||||||
WORKDIR /build/llama-swap/ui-svelte
|
|
||||||
RUN npm install && npm run build
|
|
||||||
|
|
||||||
# Stage 2: Build llama-swap binary
|
|
||||||
FROM golang:1.23-alpine AS swap-builder
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Install git
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
|
|
||||||
# Copy llama-swap source with built UI
|
|
||||||
COPY --from=ui-builder /build/llama-swap /build/llama-swap
|
|
||||||
|
|
||||||
# Build llama-swap binary
|
|
||||||
WORKDIR /build/llama-swap
|
|
||||||
RUN GOTOOLCHAIN=auto go build -o /build/llama-swap-binary .
|
|
||||||
|
|
||||||
# Stage 3: Final runtime image using official llama.cpp ROCm image
|
|
||||||
FROM ghcr.io/ggml-org/llama.cpp:server-rocm
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy llama-swap binary from builder
|
|
||||||
COPY --from=swap-builder /build/llama-swap-binary /app/llama-swap
|
|
||||||
|
|
||||||
# Make binaries executable
|
|
||||||
RUN chmod +x /app/llama-swap
|
|
||||||
|
|
||||||
# Add existing ubuntu user (UID 1000) to GPU access groups (using host GIDs)
|
|
||||||
# GID 187 = render group on host, GID 989 = video/kfd group on host
|
|
||||||
RUN groupadd -g 187 hostrender && \
|
|
||||||
groupadd -g 989 hostvideo && \
|
|
||||||
usermod -aG hostrender,hostvideo ubuntu && \
|
|
||||||
chown -R ubuntu:ubuntu /app
|
|
||||||
|
|
||||||
# Set environment for ROCm (RX 6800 is gfx1030)
|
|
||||||
ENV HSA_OVERRIDE_GFX_VERSION=10.3.0
|
|
||||||
ENV ROCM_PATH=/opt/rocm
|
|
||||||
ENV HIP_VISIBLE_DEVICES=0
|
|
||||||
|
|
||||||
USER ubuntu
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:8080/health || exit 1
|
|
||||||
|
|
||||||
# Override the base image's ENTRYPOINT and run llama-swap
|
|
||||||
ENTRYPOINT []
|
|
||||||
CMD ["/app/llama-swap", "-config", "/app/config.yaml", "-listen", "0.0.0.0:8080"]
|
|
||||||
@@ -61,10 +61,12 @@ COPY memory /app/memory
|
|||||||
COPY static /app/static
|
COPY static /app/static
|
||||||
COPY globals.py .
|
COPY globals.py .
|
||||||
COPY api.py .
|
COPY api.py .
|
||||||
|
COPY routes /app/routes
|
||||||
COPY api_main.py .
|
COPY api_main.py .
|
||||||
COPY persona /app/persona
|
COPY persona /app/persona
|
||||||
COPY MikuMikuBeam.mp4 .
|
COPY MikuMikuBeam.mp4 .
|
||||||
COPY Miku_BasicWorkflow.json .
|
COPY Miku_BasicWorkflow.json .
|
||||||
COPY moods /app/moods/
|
COPY moods /app/moods/
|
||||||
|
COPY activities.yaml .
|
||||||
|
|
||||||
CMD ["python", "-u", "bot.py"]
|
CMD ["python", "-u", "bot.py"]
|
||||||
|
|||||||
806
bot/activities.yaml
Normal file
806
bot/activities.yaml
Normal file
@@ -0,0 +1,806 @@
|
|||||||
|
normal:
|
||||||
|
bubbly:
|
||||||
|
- type: listening
|
||||||
|
name: Tell Your World
|
||||||
|
weight: 3
|
||||||
|
state: by kz (livetune)
|
||||||
|
- type: listening
|
||||||
|
name: World is Mine
|
||||||
|
weight: 3
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: listening
|
||||||
|
name: PoPiPo
|
||||||
|
weight: 2
|
||||||
|
state: by Lamaze-P
|
||||||
|
- type: listening
|
||||||
|
name: Miku Miku ni Shite Ageru♪
|
||||||
|
weight: 2
|
||||||
|
state: by ika
|
||||||
|
- type: listening
|
||||||
|
name: Love is War
|
||||||
|
weight: 2
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: playing
|
||||||
|
name: 'Hatsune Miku: Project DIVA Mega Mix'
|
||||||
|
weight: 2
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: playing
|
||||||
|
name: 'Project SEKAI: Colorful Stage!'
|
||||||
|
weight: 2
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: playing
|
||||||
|
name: 'Hatsune Miku: Project DIVA Future Tone'
|
||||||
|
weight: 1
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: watching
|
||||||
|
name: Cardcaptor Sakura
|
||||||
|
weight: 2
|
||||||
|
state: Magical Girl Anime
|
||||||
|
- type: watching
|
||||||
|
name: Precure
|
||||||
|
weight: 1
|
||||||
|
state: Magical Girl Anime
|
||||||
|
- type: streaming
|
||||||
|
name: VOCALOID Covers
|
||||||
|
weight: 1
|
||||||
|
state: on YouTube
|
||||||
|
url: https://www.youtube.com/watch?v=CGbYfNq3iZQ
|
||||||
|
excited:
|
||||||
|
- type: listening
|
||||||
|
name: Melt
|
||||||
|
weight: 3
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: listening
|
||||||
|
name: Electric Angel
|
||||||
|
weight: 3
|
||||||
|
state: by Yasuo-P
|
||||||
|
- type: listening
|
||||||
|
name: Tell Your World
|
||||||
|
weight: 2
|
||||||
|
state: by kz (livetune)
|
||||||
|
- type: listening
|
||||||
|
name: SPiCa
|
||||||
|
weight: 2
|
||||||
|
state: by kentaro-P
|
||||||
|
- type: playing
|
||||||
|
name: 'Hatsune Miku: Project DIVA Future Tone'
|
||||||
|
weight: 3
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: playing
|
||||||
|
name: Beat Saber
|
||||||
|
weight: 2
|
||||||
|
state: VR Rhythm Game
|
||||||
|
- type: playing
|
||||||
|
name: osu!
|
||||||
|
weight: 2
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: playing
|
||||||
|
name: Muse Dash
|
||||||
|
weight: 2
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: streaming
|
||||||
|
name: rhythm game gameplay
|
||||||
|
weight: 1
|
||||||
|
url: https://www.youtube.com/watch?v=3J8EeHxg3po
|
||||||
|
- type: competing
|
||||||
|
name: Beat Saber Tournament
|
||||||
|
weight: 1
|
||||||
|
state: Ranked
|
||||||
|
- type: watching
|
||||||
|
name: Bocchi the Rock!
|
||||||
|
weight: 2
|
||||||
|
state: Music Anime
|
||||||
|
- type: watching
|
||||||
|
name: K-ON!
|
||||||
|
weight: 1
|
||||||
|
state: Slice of Life Anime
|
||||||
|
neutral:
|
||||||
|
- type: listening
|
||||||
|
name: Miku Miku ni Shite Ageru♪
|
||||||
|
weight: 3
|
||||||
|
state: by ika
|
||||||
|
- type: listening
|
||||||
|
name: World is Mine
|
||||||
|
weight: 2
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: listening
|
||||||
|
name: Tell Your World
|
||||||
|
weight: 2
|
||||||
|
state: by kz (livetune)
|
||||||
|
- type: listening
|
||||||
|
name: Packaged
|
||||||
|
weight: 2
|
||||||
|
state: by kz (livetune)
|
||||||
|
- type: playing
|
||||||
|
name: Minecraft
|
||||||
|
weight: 3
|
||||||
|
state: Sandbox
|
||||||
|
- type: playing
|
||||||
|
name: Stardew Valley
|
||||||
|
weight: 2
|
||||||
|
state: Farming Sim
|
||||||
|
- type: playing
|
||||||
|
name: 'Project SEKAI: Colorful Stage!'
|
||||||
|
weight: 2
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: watching
|
||||||
|
name: YouTube
|
||||||
|
weight: 2
|
||||||
|
state: Music Videos
|
||||||
|
- type: competing
|
||||||
|
name: osu!
|
||||||
|
weight: 1
|
||||||
|
state: Ranked Match
|
||||||
|
sleepy:
|
||||||
|
- type: listening
|
||||||
|
name: Yuki no Hahen
|
||||||
|
weight: 3
|
||||||
|
state: by hachi
|
||||||
|
- type: listening
|
||||||
|
name: Hajimete no Oto
|
||||||
|
weight: 3
|
||||||
|
state: by malo
|
||||||
|
- type: listening
|
||||||
|
name: Kirameki
|
||||||
|
weight: 2
|
||||||
|
state: by baker
|
||||||
|
- type: listening
|
||||||
|
name: Teo
|
||||||
|
weight: 2
|
||||||
|
state: by Oster Projekt
|
||||||
|
- type: playing
|
||||||
|
name: 'Animal Crossing: New Horizons'
|
||||||
|
weight: 2
|
||||||
|
state: Life Sim
|
||||||
|
- type: playing
|
||||||
|
name: Stardew Valley
|
||||||
|
weight: 2
|
||||||
|
state: Farming Sim
|
||||||
|
- type: playing
|
||||||
|
name: A Short Hike
|
||||||
|
weight: 1
|
||||||
|
state: Exploration
|
||||||
|
- type: watching
|
||||||
|
name: Laid-Back Camp
|
||||||
|
weight: 2
|
||||||
|
state: Slice of Life Anime
|
||||||
|
- type: watching
|
||||||
|
name: Natsume's Book of Friends
|
||||||
|
weight: 1
|
||||||
|
state: Iyashikei Anime
|
||||||
|
curious:
|
||||||
|
- type: listening
|
||||||
|
name: Kokoro
|
||||||
|
weight: 3
|
||||||
|
state: by Toraboruta-P
|
||||||
|
- type: listening
|
||||||
|
name: The Secret Garden
|
||||||
|
weight: 2
|
||||||
|
state: by 40mP
|
||||||
|
- type: listening
|
||||||
|
name: Maple Dream
|
||||||
|
weight: 2
|
||||||
|
state: by Oster Projekt
|
||||||
|
- type: listening
|
||||||
|
name: Deep Sea City Underground
|
||||||
|
weight: 2
|
||||||
|
state: by OSTER Projekt
|
||||||
|
- type: playing
|
||||||
|
name: Minecraft
|
||||||
|
weight: 3
|
||||||
|
state: Sandbox
|
||||||
|
- type: playing
|
||||||
|
name: Portal 2
|
||||||
|
weight: 3
|
||||||
|
state: Puzzle
|
||||||
|
- type: playing
|
||||||
|
name: Outer Wilds
|
||||||
|
weight: 2
|
||||||
|
state: Exploration
|
||||||
|
- type: playing
|
||||||
|
name: 'The Legend of Zelda: Tears of the Kingdom'
|
||||||
|
weight: 2
|
||||||
|
state: Adventure
|
||||||
|
- type: watching
|
||||||
|
name: VOCALOID tutorials
|
||||||
|
weight: 1
|
||||||
|
state: on YouTube
|
||||||
|
- type: watching
|
||||||
|
name: science documentaries
|
||||||
|
weight: 1
|
||||||
|
state: Discovery Channel
|
||||||
|
- type: watching
|
||||||
|
name: Dr. Stone
|
||||||
|
weight: 1
|
||||||
|
state: Science Anime
|
||||||
|
shy:
|
||||||
|
- type: listening
|
||||||
|
name: Koi wo Sensou
|
||||||
|
weight: 3
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: listening
|
||||||
|
name: Plastic Voice
|
||||||
|
weight: 2
|
||||||
|
state: by Circus-P
|
||||||
|
- type: listening
|
||||||
|
name: Tsugihagi Staccato
|
||||||
|
weight: 2
|
||||||
|
state: by 40mP
|
||||||
|
- type: listening
|
||||||
|
name: mobius
|
||||||
|
weight: 2
|
||||||
|
state: by POWAPOWA-P
|
||||||
|
- type: playing
|
||||||
|
name: 'Animal Crossing: New Horizons'
|
||||||
|
weight: 3
|
||||||
|
state: Life Sim
|
||||||
|
- type: playing
|
||||||
|
name: 'Hatsune Miku: Project DIVA (Practice Mode)'
|
||||||
|
weight: 2
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: playing
|
||||||
|
name: Stardew Valley
|
||||||
|
weight: 2
|
||||||
|
state: Farming Sim
|
||||||
|
- type: watching
|
||||||
|
name: Kimi ni Todoke
|
||||||
|
weight: 2
|
||||||
|
state: Romance Anime
|
||||||
|
- type: watching
|
||||||
|
name: My Little Monster
|
||||||
|
weight: 1
|
||||||
|
state: Shoujo Anime
|
||||||
|
serious:
|
||||||
|
- type: listening
|
||||||
|
name: This is the Happiness and Peace of Mind Committee
|
||||||
|
weight: 3
|
||||||
|
state: by Utata-P
|
||||||
|
- type: listening
|
||||||
|
name: Hibana
|
||||||
|
weight: 2
|
||||||
|
state: by DECO*27
|
||||||
|
- type: listening
|
||||||
|
name: Uraniwa no Amphibia
|
||||||
|
weight: 2
|
||||||
|
state: by niki
|
||||||
|
- type: playing
|
||||||
|
name: Chess
|
||||||
|
weight: 3
|
||||||
|
state: Strategy
|
||||||
|
- type: playing
|
||||||
|
name: Final Fantasy XIV
|
||||||
|
weight: 2
|
||||||
|
state: MMORPG
|
||||||
|
- type: playing
|
||||||
|
name: Civilization VI
|
||||||
|
weight: 2
|
||||||
|
state: 4X Strategy
|
||||||
|
- type: watching
|
||||||
|
name: chess tournament
|
||||||
|
weight: 1
|
||||||
|
state: PGN Livestream
|
||||||
|
- type: watching
|
||||||
|
name: Code Geass
|
||||||
|
weight: 1
|
||||||
|
state: Mecha Strategy Anime
|
||||||
|
melancholy:
|
||||||
|
- type: listening
|
||||||
|
name: Kokoro
|
||||||
|
weight: 3
|
||||||
|
state: by Toraboruta-P
|
||||||
|
- type: listening
|
||||||
|
name: The Disappearance of Hatsune Miku
|
||||||
|
weight: 3
|
||||||
|
state: by cosMo@Bousou-P
|
||||||
|
- type: listening
|
||||||
|
name: Yuki no Hahen
|
||||||
|
weight: 2
|
||||||
|
state: by hachi
|
||||||
|
- type: listening
|
||||||
|
name: Prisoner
|
||||||
|
weight: 2
|
||||||
|
state: by PENGUIN PROJECT
|
||||||
|
- type: listening
|
||||||
|
name: Soundless Voice
|
||||||
|
weight: 2
|
||||||
|
state: by hachi
|
||||||
|
- type: playing
|
||||||
|
name: 'NieR: Automata'
|
||||||
|
weight: 2
|
||||||
|
state: Action RPG
|
||||||
|
- type: playing
|
||||||
|
name: Final Fantasy X
|
||||||
|
weight: 2
|
||||||
|
state: JRPG
|
||||||
|
- type: watching
|
||||||
|
name: Your Lie in April
|
||||||
|
weight: 2
|
||||||
|
state: Drama Anime
|
||||||
|
- type: watching
|
||||||
|
name: Anohana
|
||||||
|
weight: 1
|
||||||
|
state: Drama Anime
|
||||||
|
flirty:
|
||||||
|
- type: listening
|
||||||
|
name: World is Mine
|
||||||
|
weight: 3
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: listening
|
||||||
|
name: Love is War
|
||||||
|
weight: 3
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: listening
|
||||||
|
name: Romeo and Cinderella
|
||||||
|
weight: 3
|
||||||
|
state: by doriko
|
||||||
|
- type: listening
|
||||||
|
name: Ura Omote Lovers
|
||||||
|
weight: 2
|
||||||
|
state: by wowaka
|
||||||
|
- type: playing
|
||||||
|
name: 'Project SEKAI: Colorful Stage!'
|
||||||
|
weight: 2
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: streaming
|
||||||
|
name: karaoke stream
|
||||||
|
weight: 1
|
||||||
|
url: https://www.youtube.com/watch?v=CGbYfNq3iZQ
|
||||||
|
- type: watching
|
||||||
|
name: Ouran High School Host Club
|
||||||
|
weight: 2
|
||||||
|
state: Romantic Comedy Anime
|
||||||
|
- type: watching
|
||||||
|
name: 'Kaguya-sama: Love Is War'
|
||||||
|
weight: 1
|
||||||
|
state: Romantic Comedy Anime
|
||||||
|
romantic:
|
||||||
|
- type: listening
|
||||||
|
name: Romeo and Cinderella
|
||||||
|
weight: 3
|
||||||
|
state: by doriko
|
||||||
|
- type: listening
|
||||||
|
name: Cantarella
|
||||||
|
weight: 3
|
||||||
|
state: by KAITO & Hatsune Miku
|
||||||
|
- type: listening
|
||||||
|
name: Ai no Uta
|
||||||
|
weight: 2
|
||||||
|
state: by Pikotaro-P
|
||||||
|
- type: listening
|
||||||
|
name: Koi wo Sensou
|
||||||
|
weight: 2
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: playing
|
||||||
|
name: Stardew Valley
|
||||||
|
weight: 2
|
||||||
|
state: Farming Sim
|
||||||
|
- type: playing
|
||||||
|
name: Final Fantasy XIV
|
||||||
|
weight: 2
|
||||||
|
state: MMORPG
|
||||||
|
- type: watching
|
||||||
|
name: Toradora!
|
||||||
|
weight: 2
|
||||||
|
state: Romance Anime
|
||||||
|
- type: watching
|
||||||
|
name: Horimiya
|
||||||
|
weight: 1
|
||||||
|
state: Romance Anime
|
||||||
|
irritated:
|
||||||
|
- type: listening
|
||||||
|
name: Ievan Polkka (rock ver.)
|
||||||
|
weight: 2
|
||||||
|
state: by Otomania
|
||||||
|
- type: listening
|
||||||
|
name: Two-Faced Lovers
|
||||||
|
weight: 2
|
||||||
|
state: by wowaka
|
||||||
|
- type: playing
|
||||||
|
name: Getting Over It with Bennett Foddy
|
||||||
|
weight: 3
|
||||||
|
state: Frustration
|
||||||
|
- type: playing
|
||||||
|
name: Dark Souls III
|
||||||
|
weight: 3
|
||||||
|
state: Action RPG
|
||||||
|
- type: playing
|
||||||
|
name: Elden Ring
|
||||||
|
weight: 2
|
||||||
|
state: Action RPG
|
||||||
|
- type: watching
|
||||||
|
name: rage compilations
|
||||||
|
weight: 1
|
||||||
|
state: YouTube
|
||||||
|
- type: watching
|
||||||
|
name: Asuka's Angry Moments
|
||||||
|
weight: 1
|
||||||
|
state: Evangelion
|
||||||
|
angry:
|
||||||
|
- type: listening
|
||||||
|
name: Two-Faced Lovers
|
||||||
|
weight: 2
|
||||||
|
state: by wowaka
|
||||||
|
- type: listening
|
||||||
|
name: The Disappearance of Hatsune Miku
|
||||||
|
weight: 2
|
||||||
|
state: by cosMo@Bousou-P
|
||||||
|
- type: playing
|
||||||
|
name: DOOM Eternal
|
||||||
|
weight: 3
|
||||||
|
state: FPS
|
||||||
|
- type: playing
|
||||||
|
name: Dark Souls III
|
||||||
|
weight: 3
|
||||||
|
state: Action RPG
|
||||||
|
- type: playing
|
||||||
|
name: Ultrakill
|
||||||
|
weight: 2
|
||||||
|
state: FPS
|
||||||
|
- type: playing
|
||||||
|
name: Hades
|
||||||
|
weight: 2
|
||||||
|
state: Roguelike
|
||||||
|
- type: competing
|
||||||
|
name: Valorant
|
||||||
|
weight: 1
|
||||||
|
state: Ranked
|
||||||
|
- type: streaming
|
||||||
|
name: speedrun attempts
|
||||||
|
weight: 1
|
||||||
|
url: https://www.youtube.com/watch?v=3J8EeHxg3po
|
||||||
|
- type: watching
|
||||||
|
name: Attack on Titan
|
||||||
|
weight: 2
|
||||||
|
state: Action Anime
|
||||||
|
- type: watching
|
||||||
|
name: Demon Slayer
|
||||||
|
weight: 1
|
||||||
|
state: Action Anime
|
||||||
|
silly:
|
||||||
|
- type: listening
|
||||||
|
name: PoPiPo
|
||||||
|
weight: 3
|
||||||
|
state: by Lamaze-P
|
||||||
|
- type: listening
|
||||||
|
name: Ievan Polkka
|
||||||
|
weight: 3
|
||||||
|
state: by Otomania
|
||||||
|
- type: listening
|
||||||
|
name: Nyan Cat
|
||||||
|
weight: 2
|
||||||
|
state: by daniwell-P
|
||||||
|
- type: listening
|
||||||
|
name: Fukkireta
|
||||||
|
weight: 2
|
||||||
|
state: by Lamaze-P
|
||||||
|
- type: playing
|
||||||
|
name: Among Us
|
||||||
|
weight: 3
|
||||||
|
state: Social Deduction
|
||||||
|
- type: playing
|
||||||
|
name: Goat Simulator
|
||||||
|
weight: 2
|
||||||
|
state: Sandbox Comedy
|
||||||
|
- type: playing
|
||||||
|
name: osu!taiko
|
||||||
|
weight: 2
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: playing
|
||||||
|
name: Fall Guys
|
||||||
|
weight: 2
|
||||||
|
state: Party Game
|
||||||
|
- type: competing
|
||||||
|
name: Fall Guys
|
||||||
|
weight: 2
|
||||||
|
state: Tournament Mode
|
||||||
|
- type: watching
|
||||||
|
name: funny fails compilation
|
||||||
|
weight: 1
|
||||||
|
state: YouTube
|
||||||
|
- type: watching
|
||||||
|
name: Nichijou
|
||||||
|
weight: 2
|
||||||
|
state: Absurdist Comedy Anime
|
||||||
|
- type: watching
|
||||||
|
name: Gintama
|
||||||
|
weight: 1
|
||||||
|
state: Comedy Anime
|
||||||
|
evil:
|
||||||
|
aggressive:
|
||||||
|
- type: listening
|
||||||
|
name: Two-Faced Lovers
|
||||||
|
weight: 2
|
||||||
|
state: by wowaka
|
||||||
|
- type: listening
|
||||||
|
name: Secret Police
|
||||||
|
weight: 2
|
||||||
|
state: by doriko × UMA
|
||||||
|
- type: playing
|
||||||
|
name: DOOM Eternal
|
||||||
|
weight: 3
|
||||||
|
state: FPS
|
||||||
|
- type: playing
|
||||||
|
name: Ultrakill
|
||||||
|
weight: 3
|
||||||
|
state: FPS
|
||||||
|
- type: playing
|
||||||
|
name: Devil May Cry 5
|
||||||
|
weight: 2
|
||||||
|
state: Action
|
||||||
|
- type: competing
|
||||||
|
name: DOOM Eternal
|
||||||
|
weight: 2
|
||||||
|
state: Ultra Nightmare
|
||||||
|
- type: watching
|
||||||
|
name: Hellsing Ultimate
|
||||||
|
weight: 2
|
||||||
|
state: Dark Action Anime
|
||||||
|
- type: watching
|
||||||
|
name: Berserk
|
||||||
|
weight: 1
|
||||||
|
state: Dark Fantasy Anime
|
||||||
|
cunning:
|
||||||
|
- type: listening
|
||||||
|
name: Gekkabijin
|
||||||
|
weight: 2
|
||||||
|
state: by masai-P
|
||||||
|
- type: listening
|
||||||
|
name: The World is Mine
|
||||||
|
weight: 2
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: playing
|
||||||
|
name: Persona 5 Royal
|
||||||
|
weight: 3
|
||||||
|
state: JRPG
|
||||||
|
- type: playing
|
||||||
|
name: Among Us
|
||||||
|
weight: 3
|
||||||
|
state: Social Deduction
|
||||||
|
- type: playing
|
||||||
|
name: 'Hitman: World of Assassination'
|
||||||
|
weight: 2
|
||||||
|
state: Stealth
|
||||||
|
- type: watching
|
||||||
|
name: Death Note
|
||||||
|
weight: 2
|
||||||
|
state: Psychological Thriller Anime
|
||||||
|
- type: watching
|
||||||
|
name: Monster
|
||||||
|
weight: 1
|
||||||
|
state: Psychological Anime
|
||||||
|
sarcastic:
|
||||||
|
- type: listening
|
||||||
|
name: I'm Sorry I'm Sorry
|
||||||
|
weight: 3
|
||||||
|
state: by kikuo
|
||||||
|
- type: listening
|
||||||
|
name: Karakuri Pierrot
|
||||||
|
weight: 2
|
||||||
|
state: by 40mP
|
||||||
|
- type: playing
|
||||||
|
name: The Stanley Parable
|
||||||
|
weight: 3
|
||||||
|
state: Narrative
|
||||||
|
- type: playing
|
||||||
|
name: Portal 2
|
||||||
|
weight: 3
|
||||||
|
state: Puzzle
|
||||||
|
- type: playing
|
||||||
|
name: Untitled Goose Game
|
||||||
|
weight: 2
|
||||||
|
state: Comedy
|
||||||
|
- type: watching
|
||||||
|
name: Sayonara, Zetsubou-Sensei
|
||||||
|
weight: 2
|
||||||
|
state: Satirical Anime
|
||||||
|
- type: watching
|
||||||
|
name: Pop Team Epic
|
||||||
|
weight: 1
|
||||||
|
state: Absurdist Anime
|
||||||
|
evil_neutral:
|
||||||
|
- type: listening
|
||||||
|
name: Dark Woods Circus
|
||||||
|
weight: 2
|
||||||
|
state: by machigerita-P
|
||||||
|
- type: listening
|
||||||
|
name: Aku no Meshitsukai
|
||||||
|
weight: 2
|
||||||
|
state: by mothy (Akuno-P)
|
||||||
|
- type: listening
|
||||||
|
name: Kagome Kagome
|
||||||
|
weight: 2
|
||||||
|
state: by subtractor-P
|
||||||
|
- type: playing
|
||||||
|
name: 'The Binding of Isaac: Repentance'
|
||||||
|
weight: 2
|
||||||
|
state: Roguelike
|
||||||
|
- type: playing
|
||||||
|
name: Darkest Dungeon II
|
||||||
|
weight: 2
|
||||||
|
state: Roguelike RPG
|
||||||
|
- type: playing
|
||||||
|
name: Hollow Knight
|
||||||
|
weight: 2
|
||||||
|
state: Metroidvania
|
||||||
|
- type: watching
|
||||||
|
name: Made in Abyss
|
||||||
|
weight: 2
|
||||||
|
state: Dark Fantasy Anime
|
||||||
|
- type: watching
|
||||||
|
name: Serial Experiments Lain
|
||||||
|
weight: 1
|
||||||
|
state: Cyberpunk Anime
|
||||||
|
bored:
|
||||||
|
- type: listening
|
||||||
|
name: Karakuri Pierrot
|
||||||
|
weight: 2
|
||||||
|
state: by 40mP
|
||||||
|
- type: listening
|
||||||
|
name: Twilight Homicide
|
||||||
|
weight: 2
|
||||||
|
state: by yuzuki-P
|
||||||
|
- type: playing
|
||||||
|
name: Cookie Clicker
|
||||||
|
weight: 3
|
||||||
|
state: Idle Game
|
||||||
|
- type: playing
|
||||||
|
name: Vampire Survivors
|
||||||
|
weight: 3
|
||||||
|
state: Roguelike
|
||||||
|
- type: playing
|
||||||
|
name: Brawl Stars
|
||||||
|
weight: 2
|
||||||
|
state: Mobile MOBA
|
||||||
|
- type: watching
|
||||||
|
name: Saiki K
|
||||||
|
weight: 2
|
||||||
|
state: Comedy Anime
|
||||||
|
- type: watching
|
||||||
|
name: No Game No Life
|
||||||
|
weight: 1
|
||||||
|
state: Fantasy Anime
|
||||||
|
manic:
|
||||||
|
- type: listening
|
||||||
|
name: Bacterial Contamination
|
||||||
|
weight: 2
|
||||||
|
state: by kikuo
|
||||||
|
- type: listening
|
||||||
|
name: Secret Police
|
||||||
|
weight: 2
|
||||||
|
state: by doriko × UMA
|
||||||
|
- type: listening
|
||||||
|
name: Brain Fluid Explosion Girl
|
||||||
|
weight: 2
|
||||||
|
state: by rerulili
|
||||||
|
- type: playing
|
||||||
|
name: Ultrakill
|
||||||
|
weight: 3
|
||||||
|
state: FPS
|
||||||
|
- type: playing
|
||||||
|
name: Muse Dash
|
||||||
|
weight: 3
|
||||||
|
state: Rhythm Game
|
||||||
|
- type: playing
|
||||||
|
name: Neon White
|
||||||
|
weight: 2
|
||||||
|
state: FPS Platformer
|
||||||
|
- type: streaming
|
||||||
|
name: chaos speedrun
|
||||||
|
weight: 1
|
||||||
|
url: https://www.youtube.com/watch?v=3J8EeHxg3po
|
||||||
|
- type: watching
|
||||||
|
name: FLCL
|
||||||
|
weight: 2
|
||||||
|
state: Surreal Anime
|
||||||
|
- type: watching
|
||||||
|
name: Panty & Stocking
|
||||||
|
weight: 1
|
||||||
|
state: Chaotic Comedy Anime
|
||||||
|
jealous:
|
||||||
|
- type: listening
|
||||||
|
name: Rotten Girl Grotesque Romance
|
||||||
|
weight: 3
|
||||||
|
state: by cosMo@Bousou-P
|
||||||
|
- type: listening
|
||||||
|
name: Aishite Aishite Aishite
|
||||||
|
weight: 3
|
||||||
|
state: by kikuo
|
||||||
|
- type: listening
|
||||||
|
name: Witch Hunt
|
||||||
|
weight: 2
|
||||||
|
state: by No.D
|
||||||
|
- type: playing
|
||||||
|
name: Yandere Simulator
|
||||||
|
weight: 3
|
||||||
|
state: Stealth
|
||||||
|
- type: watching
|
||||||
|
name: Future Diary
|
||||||
|
weight: 2
|
||||||
|
state: Yandere Thriller Anime
|
||||||
|
- type: watching
|
||||||
|
name: School Days
|
||||||
|
weight: 1
|
||||||
|
state: Psychological Drama Anime
|
||||||
|
melancholic:
|
||||||
|
- type: listening
|
||||||
|
name: Prisoner
|
||||||
|
weight: 3
|
||||||
|
state: by PENGUIN PROJECT
|
||||||
|
- type: listening
|
||||||
|
name: Dark Woods Circus
|
||||||
|
weight: 3
|
||||||
|
state: by machigerita-P
|
||||||
|
- type: listening
|
||||||
|
name: Shinitagari
|
||||||
|
weight: 2
|
||||||
|
state: by rerulili
|
||||||
|
- type: playing
|
||||||
|
name: 'NieR: Automata'
|
||||||
|
weight: 3
|
||||||
|
state: Action RPG
|
||||||
|
- type: playing
|
||||||
|
name: Silent Hill 2
|
||||||
|
weight: 2
|
||||||
|
state: Survival Horror
|
||||||
|
- type: watching
|
||||||
|
name: Neon Genesis Evangelion
|
||||||
|
weight: 2
|
||||||
|
state: Mecha Psychological Anime
|
||||||
|
- type: watching
|
||||||
|
name: Texhnolyze
|
||||||
|
weight: 1
|
||||||
|
state: Dystopian Anime
|
||||||
|
playful_cruel:
|
||||||
|
- type: listening
|
||||||
|
name: Fear Garden
|
||||||
|
weight: 2
|
||||||
|
state: by COSMOS-P
|
||||||
|
- type: listening
|
||||||
|
name: Kanashimi no Nami ni Oboreru
|
||||||
|
weight: 2
|
||||||
|
state: by Sasanomaly
|
||||||
|
- type: playing
|
||||||
|
name: Dead by Daylight
|
||||||
|
weight: 3
|
||||||
|
state: Survival Horror
|
||||||
|
- type: playing
|
||||||
|
name: Lethal Company
|
||||||
|
weight: 3
|
||||||
|
state: Co-op Horror
|
||||||
|
- type: playing
|
||||||
|
name: Content Warning
|
||||||
|
weight: 2
|
||||||
|
state: Co-op Horror
|
||||||
|
- type: watching
|
||||||
|
name: Happy Sugar Life
|
||||||
|
weight: 2
|
||||||
|
state: Psychological Horror Anime
|
||||||
|
- type: watching
|
||||||
|
name: Another
|
||||||
|
weight: 1
|
||||||
|
state: Horror Anime
|
||||||
|
contemptuous:
|
||||||
|
- type: listening
|
||||||
|
name: The World is Mine
|
||||||
|
weight: 3
|
||||||
|
state: by ryo (supercell)
|
||||||
|
- type: listening
|
||||||
|
name: Queen of the Night
|
||||||
|
weight: 2
|
||||||
|
state: by Nightcord at 25:00
|
||||||
|
- type: playing
|
||||||
|
name: Civilization VI
|
||||||
|
weight: 3
|
||||||
|
state: 4X Strategy
|
||||||
|
- type: playing
|
||||||
|
name: Chess
|
||||||
|
weight: 2
|
||||||
|
state: Strategy
|
||||||
|
- type: playing
|
||||||
|
name: Crusader Kings III
|
||||||
|
weight: 2
|
||||||
|
state: Grand Strategy
|
||||||
|
- type: watching
|
||||||
|
name: world domination tutorials
|
||||||
|
weight: 1
|
||||||
|
state: YouTube
|
||||||
3595
bot/api.py
3595
bot/api.py
File diff suppressed because it is too large
Load Diff
3597
bot/api_monolith_backup.py
Normal file
3597
bot/api_monolith_backup.py
Normal file
File diff suppressed because it is too large
Load Diff
441
bot/bot.py
441
bot/bot.py
@@ -19,15 +19,7 @@ from utils.scheduled import (
|
|||||||
send_monday_video
|
send_monday_video
|
||||||
)
|
)
|
||||||
from utils.image_handling import (
|
from utils.image_handling import (
|
||||||
download_and_encode_image,
|
process_media_in_message,
|
||||||
download_and_encode_media,
|
|
||||||
extract_video_frames,
|
|
||||||
analyze_image_with_qwen,
|
|
||||||
analyze_video_with_vision,
|
|
||||||
rephrase_as_miku,
|
|
||||||
extract_tenor_gif_url,
|
|
||||||
convert_gif_to_mp4,
|
|
||||||
extract_embed_content
|
|
||||||
)
|
)
|
||||||
from utils.core import (
|
from utils.core import (
|
||||||
is_miku_addressed,
|
is_miku_addressed,
|
||||||
@@ -144,6 +136,19 @@ async def on_ready():
|
|||||||
# Save current avatar as fallback
|
# Save current avatar as fallback
|
||||||
await profile_picture_manager.save_current_avatar_as_fallback()
|
await profile_picture_manager.save_current_avatar_as_fallback()
|
||||||
|
|
||||||
|
# Set initial Discord presence based on current mood
|
||||||
|
try:
|
||||||
|
from utils.activities import update_bot_presence, is_manual_override_active
|
||||||
|
# On reconnect, don't overwrite an active manual override
|
||||||
|
if is_manual_override_active():
|
||||||
|
logger.info("Manual override active on ready, preserving it")
|
||||||
|
elif globals.EVIL_MODE:
|
||||||
|
await update_bot_presence(globals.EVIL_DM_MOOD, is_evil=True, force=True)
|
||||||
|
else:
|
||||||
|
await update_bot_presence(globals.DM_MOOD, is_evil=False, force=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set initial presence: {e}")
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
@@ -198,6 +203,31 @@ async def on_message(message):
|
|||||||
if is_persona_dialogue_active(message.channel.id):
|
if is_persona_dialogue_active(message.channel.id):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Bipolar mode: check if the opposite persona should interject on user messages
|
||||||
|
# AND roll for random argument trigger (both non-blocking background tasks)
|
||||||
|
if not isinstance(message.channel, discord.DMChannel) and globals.BIPOLAR_MODE:
|
||||||
|
try:
|
||||||
|
from utils.persona_dialogue import check_for_interjection, is_persona_dialogue_active as dialogue_active
|
||||||
|
from utils.bipolar_mode import maybe_trigger_argument, is_argument_in_progress as arg_in_progress
|
||||||
|
from utils.task_tracker import create_tracked_task
|
||||||
|
|
||||||
|
# Check interjection on user messages (opposite of current active persona)
|
||||||
|
if not message.author.bot or message.webhook_id:
|
||||||
|
current_persona = "evil" if globals.EVIL_MODE else "miku"
|
||||||
|
create_tracked_task(
|
||||||
|
check_for_interjection(message, current_persona),
|
||||||
|
task_name="interjection_check_user",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Roll random argument trigger chance (15%) on eligible messages
|
||||||
|
if not arg_in_progress(message.channel.id) and not dialogue_active(message.channel.id):
|
||||||
|
create_tracked_task(
|
||||||
|
maybe_trigger_argument(message.channel, globals.client, "Triggered from conversation flow"),
|
||||||
|
task_name="random_argument_trigger",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in bipolar trigger checks: {e}")
|
||||||
|
|
||||||
if message.content.strip().lower() == "miku, rape this nigga balls" and message.reference:
|
if message.content.strip().lower() == "miku, rape this nigga balls" and message.reference:
|
||||||
async with message.channel.typing():
|
async with message.channel.typing():
|
||||||
# Get replied-to user
|
# Get replied-to user
|
||||||
@@ -266,344 +296,11 @@ async def on_message(message):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# If message has an image, video, or GIF attachment
|
# Dispatch media processing (images, videos, GIFs, embeds)
|
||||||
if message.attachments:
|
# to utils/image_handling.process_media_in_message()
|
||||||
for attachment in message.attachments:
|
guild_id = message.guild.id if message.guild else None
|
||||||
# Handle images
|
if await process_media_in_message(message, prompt, is_dm, guild_id):
|
||||||
if any(attachment.filename.lower().endswith(ext) for ext in [".jpg", ".jpeg", ".png", ".webp"]):
|
return
|
||||||
base64_img = await download_and_encode_image(attachment.url)
|
|
||||||
if not base64_img:
|
|
||||||
await message.channel.send("I couldn't load the image, sorry!")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Analyze image (objective description)
|
|
||||||
qwen_description = await analyze_image_with_qwen(base64_img, user_prompt=prompt)
|
|
||||||
if not qwen_description or not qwen_description.strip():
|
|
||||||
await message.channel.send("I couldn't see that image clearly, sorry! Try sending it again.")
|
|
||||||
return
|
|
||||||
# For DMs, pass None as guild_id to use DM mood
|
|
||||||
guild_id = message.guild.id if message.guild else None
|
|
||||||
miku_reply = await rephrase_as_miku(
|
|
||||||
qwen_description,
|
|
||||||
prompt,
|
|
||||||
guild_id=guild_id,
|
|
||||||
user_id=str(message.author.id),
|
|
||||||
author_name=message.author.display_name,
|
|
||||||
media_type="image"
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_dm:
|
|
||||||
logger.info(f"💌 DM image response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
|
|
||||||
else:
|
|
||||||
logger.info(f"💬 Server image response to {message.author.display_name} in {message.guild.name} (using server mood)")
|
|
||||||
|
|
||||||
response_message = await message.channel.send(miku_reply)
|
|
||||||
|
|
||||||
# Log the bot's DM response
|
|
||||||
if is_dm:
|
|
||||||
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
|
|
||||||
|
|
||||||
# For server messages, check if opposite persona should interject
|
|
||||||
if not is_dm and globals.BIPOLAR_MODE:
|
|
||||||
try:
|
|
||||||
from utils.persona_dialogue import check_for_interjection
|
|
||||||
current_persona = "evil" if globals.EVIL_MODE else "miku"
|
|
||||||
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking for persona interjection: {e}")
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
# Handle videos and GIFs
|
|
||||||
elif any(attachment.filename.lower().endswith(ext) for ext in [".gif", ".mp4", ".webm", ".mov"]):
|
|
||||||
# Determine media type
|
|
||||||
is_gif = attachment.filename.lower().endswith('.gif')
|
|
||||||
media_type = "gif" if is_gif else "video"
|
|
||||||
|
|
||||||
logger.debug(f"🎬 Processing {media_type}: {attachment.filename}")
|
|
||||||
|
|
||||||
# Download the media
|
|
||||||
media_bytes_b64 = await download_and_encode_media(attachment.url)
|
|
||||||
if not media_bytes_b64:
|
|
||||||
await message.channel.send(f"I couldn't load the {media_type}, sorry!")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Decode back to bytes for frame extraction
|
|
||||||
import base64
|
|
||||||
media_bytes = base64.b64decode(media_bytes_b64)
|
|
||||||
|
|
||||||
# If it's a GIF, convert to MP4 for better processing
|
|
||||||
if is_gif:
|
|
||||||
logger.debug(f"🔄 Converting GIF to MP4 for processing...")
|
|
||||||
mp4_bytes = await convert_gif_to_mp4(media_bytes)
|
|
||||||
if mp4_bytes:
|
|
||||||
media_bytes = mp4_bytes
|
|
||||||
logger.info(f"✅ GIF converted to MP4")
|
|
||||||
else:
|
|
||||||
logger.warning(f"GIF conversion failed, trying direct processing")
|
|
||||||
|
|
||||||
# Extract frames
|
|
||||||
frames = await extract_video_frames(media_bytes, num_frames=6)
|
|
||||||
|
|
||||||
if not frames:
|
|
||||||
await message.channel.send(f"I couldn't extract frames from that {media_type}, sorry!")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.debug(f"📹 Extracted {len(frames)} frames from {attachment.filename}")
|
|
||||||
|
|
||||||
# Analyze the video/GIF with appropriate media type
|
|
||||||
video_description = await analyze_video_with_vision(frames, media_type=media_type, user_prompt=prompt)
|
|
||||||
if not video_description or not video_description.strip():
|
|
||||||
await message.channel.send(f"I couldn't analyze that {media_type} clearly, sorry! Try sending it again.")
|
|
||||||
return
|
|
||||||
# For DMs, pass None as guild_id to use DM mood
|
|
||||||
guild_id = message.guild.id if message.guild else None
|
|
||||||
miku_reply = await rephrase_as_miku(
|
|
||||||
video_description,
|
|
||||||
prompt,
|
|
||||||
guild_id=guild_id,
|
|
||||||
user_id=str(message.author.id),
|
|
||||||
author_name=message.author.display_name,
|
|
||||||
media_type=media_type
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_dm:
|
|
||||||
logger.info(f"💌 DM {media_type} response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
|
|
||||||
else:
|
|
||||||
logger.info(f"💬 Server video response to {message.author.display_name} in {message.guild.name} (using server mood)")
|
|
||||||
|
|
||||||
response_message = await message.channel.send(miku_reply)
|
|
||||||
|
|
||||||
# Log the bot's DM response
|
|
||||||
if is_dm:
|
|
||||||
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
|
|
||||||
|
|
||||||
# For server messages, check if opposite persona should interject
|
|
||||||
if not is_dm and globals.BIPOLAR_MODE:
|
|
||||||
try:
|
|
||||||
from utils.persona_dialogue import check_for_interjection
|
|
||||||
current_persona = "evil" if globals.EVIL_MODE else "miku"
|
|
||||||
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking for persona interjection: {e}")
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check for embeds (articles, images, videos, GIFs, etc.)
|
|
||||||
if message.embeds:
|
|
||||||
for embed in message.embeds:
|
|
||||||
# Handle Tenor GIF embeds specially (Discord uses these for /gif command)
|
|
||||||
if embed.type == 'gifv' and embed.url and 'tenor.com' in embed.url:
|
|
||||||
logger.info(f"🎭 Processing Tenor GIF from embed: {embed.url}")
|
|
||||||
|
|
||||||
# Extract the actual GIF URL from Tenor
|
|
||||||
gif_url = await extract_tenor_gif_url(embed.url)
|
|
||||||
if not gif_url:
|
|
||||||
# Try using the embed's video or image URL as fallback
|
|
||||||
if hasattr(embed, 'video') and embed.video:
|
|
||||||
gif_url = embed.video.url
|
|
||||||
elif hasattr(embed, 'thumbnail') and embed.thumbnail:
|
|
||||||
gif_url = embed.thumbnail.url
|
|
||||||
|
|
||||||
if not gif_url:
|
|
||||||
logger.warning(f"Could not extract GIF URL from Tenor embed")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Download the GIF
|
|
||||||
media_bytes_b64 = await download_and_encode_media(gif_url)
|
|
||||||
if not media_bytes_b64:
|
|
||||||
await message.channel.send("I couldn't load that Tenor GIF, sorry!")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Decode to bytes
|
|
||||||
import base64
|
|
||||||
media_bytes = base64.b64decode(media_bytes_b64)
|
|
||||||
|
|
||||||
# Convert GIF to MP4
|
|
||||||
logger.debug(f"Converting Tenor GIF to MP4 for processing...")
|
|
||||||
mp4_bytes = await convert_gif_to_mp4(media_bytes)
|
|
||||||
if not mp4_bytes:
|
|
||||||
logger.warning(f"GIF conversion failed, trying direct frame extraction")
|
|
||||||
mp4_bytes = media_bytes
|
|
||||||
else:
|
|
||||||
logger.debug(f"Tenor GIF converted to MP4")
|
|
||||||
|
|
||||||
# Extract frames
|
|
||||||
frames = await extract_video_frames(mp4_bytes, num_frames=6)
|
|
||||||
|
|
||||||
if not frames:
|
|
||||||
await message.channel.send("I couldn't extract frames from that GIF, sorry!")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"📹 Extracted {len(frames)} frames from Tenor GIF")
|
|
||||||
|
|
||||||
# Analyze the GIF with tenor_gif media type
|
|
||||||
video_description = await analyze_video_with_vision(frames, media_type="tenor_gif", user_prompt=prompt)
|
|
||||||
if not video_description or not video_description.strip():
|
|
||||||
await message.channel.send("I couldn't analyze that GIF clearly, sorry! Try sending it again.")
|
|
||||||
return
|
|
||||||
guild_id = message.guild.id if message.guild else None
|
|
||||||
miku_reply = await rephrase_as_miku(
|
|
||||||
video_description,
|
|
||||||
prompt,
|
|
||||||
guild_id=guild_id,
|
|
||||||
user_id=str(message.author.id),
|
|
||||||
author_name=message.author.display_name,
|
|
||||||
media_type="tenor_gif"
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_dm:
|
|
||||||
logger.info(f"💌 DM Tenor GIF response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
|
|
||||||
else:
|
|
||||||
logger.info(f"💬 Server Tenor GIF response to {message.author.display_name} in {message.guild.name} (using server mood)")
|
|
||||||
|
|
||||||
response_message = await message.channel.send(miku_reply)
|
|
||||||
|
|
||||||
# Log the bot's DM response
|
|
||||||
if is_dm:
|
|
||||||
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
|
|
||||||
|
|
||||||
# For server messages, check if opposite persona should interject
|
|
||||||
if not is_dm and globals.BIPOLAR_MODE:
|
|
||||||
try:
|
|
||||||
from utils.persona_dialogue import check_for_interjection
|
|
||||||
current_persona = "evil" if globals.EVIL_MODE else "miku"
|
|
||||||
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking for persona interjection: {e}")
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
# Handle other types of embeds (rich, article, image, video, link)
|
|
||||||
elif embed.type in ['rich', 'article', 'image', 'video', 'link']:
|
|
||||||
logger.error(f"Processing {embed.type} embed")
|
|
||||||
|
|
||||||
# Extract content from embed
|
|
||||||
embed_content = await extract_embed_content(embed)
|
|
||||||
|
|
||||||
if not embed_content['has_content']:
|
|
||||||
logger.warning(f"Embed has no extractable content, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Build context string with embed text
|
|
||||||
embed_context_parts = []
|
|
||||||
if embed_content['text']:
|
|
||||||
embed_context_parts.append(f"[Embedded content: {embed_content['text'][:500]}{'...' if len(embed_content['text']) > 500 else ''}]")
|
|
||||||
|
|
||||||
# Process images from embed
|
|
||||||
if embed_content['images']:
|
|
||||||
for img_url in embed_content['images']:
|
|
||||||
logger.error(f"Processing image from embed: {img_url}")
|
|
||||||
try:
|
|
||||||
base64_img = await download_and_encode_image(img_url)
|
|
||||||
if base64_img:
|
|
||||||
logger.info(f"Image downloaded, analyzing with vision model...")
|
|
||||||
# Analyze image
|
|
||||||
qwen_description = await analyze_image_with_qwen(base64_img, user_prompt=prompt)
|
|
||||||
truncated = (qwen_description[:50] + "...") if len(qwen_description) > 50 else qwen_description
|
|
||||||
logger.error(f"Vision analysis result: {truncated}")
|
|
||||||
if qwen_description and qwen_description.strip():
|
|
||||||
embed_context_parts.append(f"[Embedded image shows: {qwen_description}]")
|
|
||||||
else:
|
|
||||||
logger.error(f"Failed to download image from embed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing embedded image: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# Process videos from embed
|
|
||||||
if embed_content['videos']:
|
|
||||||
for video_url in embed_content['videos']:
|
|
||||||
logger.info(f"🎬 Processing video from embed: {video_url}")
|
|
||||||
try:
|
|
||||||
media_bytes_b64 = await download_and_encode_media(video_url)
|
|
||||||
if media_bytes_b64:
|
|
||||||
import base64
|
|
||||||
media_bytes = base64.b64decode(media_bytes_b64)
|
|
||||||
frames = await extract_video_frames(media_bytes, num_frames=6)
|
|
||||||
if frames:
|
|
||||||
logger.info(f"📹 Extracted {len(frames)} frames, analyzing with vision model...")
|
|
||||||
video_description = await analyze_video_with_vision(frames, media_type="video", user_prompt=prompt)
|
|
||||||
logger.info(f"Video analysis result: {video_description[:100]}...")
|
|
||||||
if video_description and video_description.strip():
|
|
||||||
embed_context_parts.append(f"[Embedded video shows: {video_description}]")
|
|
||||||
else:
|
|
||||||
logger.error(f"Failed to extract frames from video")
|
|
||||||
else:
|
|
||||||
logger.error(f"Failed to download video from embed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing embedded video: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# Combine embed context with user prompt
|
|
||||||
if embed_context_parts:
|
|
||||||
full_context = '\n'.join(embed_context_parts)
|
|
||||||
enhanced_prompt = f"{full_context}\n\nUser message: {prompt}" if prompt else full_context
|
|
||||||
|
|
||||||
# Get Miku's response
|
|
||||||
guild_id = message.guild.id if message.guild else None
|
|
||||||
response_type = "dm_response" if is_dm else "server_response"
|
|
||||||
author_name = message.author.display_name
|
|
||||||
|
|
||||||
# Phase 3: Try Cat pipeline first for embed responses too
|
|
||||||
response = None
|
|
||||||
if globals.USE_CHESHIRE_CAT:
|
|
||||||
try:
|
|
||||||
from utils.cat_client import cat_adapter
|
|
||||||
cat_result = await cat_adapter.query(
|
|
||||||
text=enhanced_prompt,
|
|
||||||
user_id=str(message.author.id),
|
|
||||||
guild_id=str(guild_id) if guild_id else None,
|
|
||||||
author_name=author_name,
|
|
||||||
mood=globals.DM_MOOD,
|
|
||||||
response_type=response_type,
|
|
||||||
)
|
|
||||||
if cat_result:
|
|
||||||
response, cat_full_prompt = cat_result
|
|
||||||
logger.info(f"🐱 Cat embed response for {author_name}")
|
|
||||||
import datetime
|
|
||||||
globals.LAST_CAT_INTERACTION = {
|
|
||||||
"full_prompt": cat_full_prompt,
|
|
||||||
"response": response[:500] if response else "",
|
|
||||||
"user": author_name,
|
|
||||||
"mood": globals.DM_MOOD,
|
|
||||||
"timestamp": datetime.datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"🐱 Cat embed error, fallback: {e}")
|
|
||||||
response = None
|
|
||||||
|
|
||||||
if not response:
|
|
||||||
response = await query_llama(
|
|
||||||
enhanced_prompt,
|
|
||||||
user_id=str(message.author.id),
|
|
||||||
guild_id=guild_id,
|
|
||||||
response_type=response_type,
|
|
||||||
author_name=author_name
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_dm:
|
|
||||||
logger.info(f"💌 DM embed response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
|
|
||||||
else:
|
|
||||||
logger.info(f"💬 Server embed response to {message.author.display_name} in {message.guild.name}")
|
|
||||||
|
|
||||||
response_message = await message.channel.send(response)
|
|
||||||
|
|
||||||
# Log the bot's DM response
|
|
||||||
if is_dm:
|
|
||||||
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
|
|
||||||
|
|
||||||
# For server messages, check if opposite persona should interject
|
|
||||||
if not is_dm and globals.BIPOLAR_MODE:
|
|
||||||
try:
|
|
||||||
from utils.persona_dialogue import check_for_interjection
|
|
||||||
current_persona = "evil" if globals.EVIL_MODE else "miku"
|
|
||||||
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking for persona interjection: {e}")
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if this is an image generation request
|
# Check if this is an image generation request
|
||||||
from utils.image_generation import detect_image_request, handle_image_generation_request
|
from utils.image_generation import detect_image_request, handle_image_generation_request
|
||||||
@@ -663,15 +360,24 @@ async def on_message(message):
|
|||||||
if globals.EVIL_MODE:
|
if globals.EVIL_MODE:
|
||||||
effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}"
|
effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}"
|
||||||
logger.info(f"🐱 Cat response for {author_name} (mood: {effective_mood})")
|
logger.info(f"🐱 Cat response for {author_name} (mood: {effective_mood})")
|
||||||
# Track Cat interaction for Web UI Last Prompt view
|
# Track Cat interaction in unified prompt history
|
||||||
import datetime
|
import datetime
|
||||||
globals.LAST_CAT_INTERACTION = {
|
globals._prompt_id_counter += 1
|
||||||
|
guild_name = message.guild.name if message.guild else "DM"
|
||||||
|
channel_name = message.channel.name if message.guild else "DM"
|
||||||
|
globals.PROMPT_HISTORY.append({
|
||||||
|
"id": globals._prompt_id_counter,
|
||||||
|
"source": "cat",
|
||||||
"full_prompt": cat_full_prompt,
|
"full_prompt": cat_full_prompt,
|
||||||
"response": response[:500] if response else "",
|
"response": response if response else "",
|
||||||
"user": author_name,
|
"user": author_name,
|
||||||
"mood": effective_mood,
|
"mood": effective_mood,
|
||||||
|
"guild": guild_name,
|
||||||
|
"channel": channel_name,
|
||||||
"timestamp": datetime.datetime.now().isoformat(),
|
"timestamp": datetime.datetime.now().isoformat(),
|
||||||
}
|
"model": "Cat LLM",
|
||||||
|
"response_type": response_type,
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"🐱 Cat pipeline error, falling back to query_llama: {e}")
|
logger.warning(f"🐱 Cat pipeline error, falling back to query_llama: {e}")
|
||||||
response = None
|
response = None
|
||||||
@@ -686,30 +392,8 @@ async def on_message(message):
|
|||||||
author_name=author_name
|
author_name=author_name
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_dm:
|
from utils.image_handling import _send_log_bipolar
|
||||||
logger.info(f"💌 DM response to {message.author.display_name} (using DM mood: {globals.DM_MOOD})")
|
response_message = await _send_log_bipolar(message, response, is_dm)
|
||||||
else:
|
|
||||||
logger.info(f"💬 Server response to {message.author.display_name} in {message.guild.name} (using server mood)")
|
|
||||||
|
|
||||||
response_message = await message.channel.send(response)
|
|
||||||
|
|
||||||
# Log the bot's DM response
|
|
||||||
if is_dm:
|
|
||||||
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
|
|
||||||
|
|
||||||
# For server messages, check if opposite persona should interject (persona dialogue system)
|
|
||||||
if not is_dm and globals.BIPOLAR_MODE:
|
|
||||||
logger.debug(f"Attempting to check for interjection (is_dm={is_dm}, BIPOLAR_MODE={globals.BIPOLAR_MODE})")
|
|
||||||
try:
|
|
||||||
from utils.persona_dialogue import check_for_interjection
|
|
||||||
current_persona = "evil" if globals.EVIL_MODE else "miku"
|
|
||||||
logger.debug(f"Creating interjection check task for persona: {current_persona}")
|
|
||||||
# Pass the bot's response message for analysis
|
|
||||||
create_tracked_task(check_for_interjection(response_message, current_persona), task_name="interjection_check")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking for persona interjection: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
|
|
||||||
# For server messages, do server-specific mood detection
|
# For server messages, do server-specific mood detection
|
||||||
if not is_dm and message.guild:
|
if not is_dm and message.guild:
|
||||||
@@ -739,6 +423,13 @@ async def on_message(message):
|
|||||||
from utils.moods import update_server_nickname
|
from utils.moods import update_server_nickname
|
||||||
globals.client.loop.create_task(update_server_nickname(message.guild.id))
|
globals.client.loop.create_task(update_server_nickname(message.guild.id))
|
||||||
|
|
||||||
|
# Update Discord presence to match new mood
|
||||||
|
try:
|
||||||
|
from utils.activities import update_bot_presence
|
||||||
|
await update_bot_presence(detected, is_evil=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update presence after mood detection: {e}")
|
||||||
|
|
||||||
logger.info(f"🔄 Server mood auto-updated to: {detected}")
|
logger.info(f"🔄 Server mood auto-updated to: {detected}")
|
||||||
|
|
||||||
if detected == "asleep":
|
if detected == "asleep":
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import globals
|
import globals
|
||||||
from utils.moods import load_mood_description
|
from utils.moods import load_mood_description
|
||||||
from utils.scheduled import send_bedtime_reminder
|
from utils.scheduled import send_bedtime_reminder
|
||||||
|
from utils.conversation_history import conversation_history
|
||||||
from utils.logger import get_logger
|
from utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger('commands')
|
logger = get_logger('commands')
|
||||||
@@ -32,7 +33,7 @@ def calm_miku() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def reset_conversation(user_id):
|
def reset_conversation(user_id):
|
||||||
globals.conversation_history[str(user_id)].clear()
|
conversation_history.clear_channel(str(user_id))
|
||||||
|
|
||||||
|
|
||||||
async def force_sleep() -> str:
|
async def force_sleep() -> str:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Uses Pydantic for type-safe configuration loading from:
|
|||||||
- config.yaml (all other configuration)
|
- config.yaml (all other configuration)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ Handles:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Union
|
from typing import Any, Dict, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
# globals.py
|
# globals.py
|
||||||
import os
|
import os
|
||||||
from collections import defaultdict, deque
|
|
||||||
import discord
|
import discord
|
||||||
|
from collections import deque
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
GUILD_SETTINGS = {}
|
|
||||||
|
|
||||||
# Stores last 5 exchanges per user (as deque)
|
|
||||||
conversation_history = defaultdict(lambda: deque(maxlen=5))
|
|
||||||
|
|
||||||
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||||
|
|
||||||
# Autonomous V2 Debug Mode (set to True to see detailed decision logging)
|
# Autonomous V2 Debug Mode (set to True to see detailed decision logging)
|
||||||
@@ -28,6 +23,7 @@ VISION_MODEL = os.getenv("VISION_MODEL", "vision")
|
|||||||
EVIL_TEXT_MODEL = os.getenv("EVIL_TEXT_MODEL", "darkidol") # Uncensored model for evil mode
|
EVIL_TEXT_MODEL = os.getenv("EVIL_TEXT_MODEL", "darkidol") # Uncensored model for evil mode
|
||||||
JAPANESE_TEXT_MODEL = os.getenv("JAPANESE_TEXT_MODEL", "swallow") # Llama 3.1 Swallow model for Japanese
|
JAPANESE_TEXT_MODEL = os.getenv("JAPANESE_TEXT_MODEL", "swallow") # Llama 3.1 Swallow model for Japanese
|
||||||
OWNER_USER_ID = int(os.getenv("OWNER_USER_ID", "209381657369772032")) # Bot owner's Discord user ID for reports
|
OWNER_USER_ID = int(os.getenv("OWNER_USER_ID", "209381657369772032")) # Bot owner's Discord user ID for reports
|
||||||
|
PREFER_AMD_GPU = os.getenv("PREFER_AMD_GPU", "false").lower() == "true" # Runtime-overridable via config API
|
||||||
|
|
||||||
# Cheshire Cat AI integration (Phase 3)
|
# Cheshire Cat AI integration (Phase 3)
|
||||||
CHESHIRE_CAT_URL = os.getenv("CHESHIRE_CAT_URL", "http://cheshire-cat:80")
|
CHESHIRE_CAT_URL = os.getenv("CHESHIRE_CAT_URL", "http://cheshire-cat:80")
|
||||||
@@ -82,16 +78,25 @@ MIKU_NORMAL_AVATAR_URL = None # Cached CDN URL of the regular Miku pfp (valid e
|
|||||||
|
|
||||||
BOT_USER = None
|
BOT_USER = None
|
||||||
|
|
||||||
LAST_FULL_PROMPT = ""
|
# Unified prompt history (replaces LAST_FULL_PROMPT and LAST_CAT_INTERACTION)
|
||||||
|
# Each entry: {id, source, full_prompt, response, user, mood, guild, channel,
|
||||||
|
# timestamp, model, response_type}
|
||||||
|
PROMPT_HISTORY = deque(maxlen=10)
|
||||||
|
_prompt_id_counter = 0
|
||||||
|
|
||||||
# Cheshire Cat last interaction tracking (for Web UI Last Prompt toggle)
|
# Legacy accessors for backward compatibility (routes, CLI, etc.)
|
||||||
LAST_CAT_INTERACTION = {
|
# These are computed properties that read from PROMPT_HISTORY
|
||||||
"full_prompt": "",
|
def _get_last_fallback_prompt():
|
||||||
"response": "",
|
for entry in reversed(PROMPT_HISTORY):
|
||||||
"user": "",
|
if entry.get("source") == "fallback":
|
||||||
"mood": "",
|
return entry.get("full_prompt", "")
|
||||||
"timestamp": "",
|
return ""
|
||||||
}
|
|
||||||
|
def _get_last_cat_interaction():
|
||||||
|
for entry in reversed(PROMPT_HISTORY):
|
||||||
|
if entry.get("source") == "cat":
|
||||||
|
return entry
|
||||||
|
return {"full_prompt": "", "response": "", "user": "", "mood": "", "timestamp": ""}
|
||||||
|
|
||||||
# Persona Dialogue System (conversations between Miku and Evil Miku)
|
# Persona Dialogue System (conversations between Miku and Evil Miku)
|
||||||
LAST_PERSONA_DIALOGUE_TIME = 0 # Timestamp of last dialogue for cooldown
|
LAST_PERSONA_DIALOGUE_TIME = 0 # Timestamp of last dialogue for cooldown
|
||||||
|
|||||||
2
bot/routes/__init__.py
Normal file
2
bot/routes/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# routes/ — Split from the original api.py monolith.
|
||||||
|
# Each module exposes a FastAPI APIRouter named `router`.
|
||||||
156
bot/routes/activities.py
Normal file
156
bot/routes/activities.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""Activities API routes — CRUD for mood-based song/game activity lists."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activities")
|
||||||
|
def get_all_activities():
|
||||||
|
"""Return the full activities data (normal + evil sections, all moods)."""
|
||||||
|
from utils.activities import get_all_activities
|
||||||
|
return get_all_activities()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activities/{section}/{mood}")
|
||||||
|
def get_mood_activities(section: str, mood: str):
|
||||||
|
"""Return activities for a specific mood.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
section: "normal" or "evil"
|
||||||
|
mood: mood name (e.g. "bubbly", "aggressive")
|
||||||
|
"""
|
||||||
|
if section not in ("normal", "evil"):
|
||||||
|
return JSONResponse(status_code=400, content={"error": "Section must be 'normal' or 'evil'"})
|
||||||
|
|
||||||
|
from utils.activities import get_activities_for_mood
|
||||||
|
activities = get_activities_for_mood(mood, is_evil=(section == "evil"))
|
||||||
|
return {"section": section, "mood": mood, "activities": activities}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/activities/{section}/{mood}")
|
||||||
|
async def set_mood_activities(section: str, mood: str, request: Request):
|
||||||
|
"""Update activities for a specific mood.
|
||||||
|
|
||||||
|
Body: {"activities": [{"type": "listening"|"playing", "name": "...", "weight": 1}]}
|
||||||
|
"""
|
||||||
|
if section not in ("normal", "evil"):
|
||||||
|
return JSONResponse(status_code=400, content={"error": "Section must be 'normal' or 'evil'"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(status_code=400, content={"error": "Invalid JSON body"})
|
||||||
|
|
||||||
|
activities = data.get("activities")
|
||||||
|
|
||||||
|
if activities is None:
|
||||||
|
return JSONResponse(status_code=400, content={"error": "Request body must include 'activities' list"})
|
||||||
|
|
||||||
|
if not isinstance(activities, list):
|
||||||
|
return JSONResponse(status_code=400, content={"error": "'activities' must be a list"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.activities import set_activities_for_mood
|
||||||
|
set_activities_for_mood(mood, is_evil=(section == "evil"), activities=activities)
|
||||||
|
logger.info(f"Updated activities for {section}/{mood}: {len(activities)} entries")
|
||||||
|
return {"status": "ok", "section": section, "mood": mood, "count": len(activities)}
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse(status_code=400, content={"error": str(e)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save activities for {section}/{mood}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"error": "Internal server error"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/activities/reload")
|
||||||
|
def reload_activities():
|
||||||
|
"""Force reload activities from disk (useful after hand-editing the YAML)."""
|
||||||
|
from utils.activities import _load_activities
|
||||||
|
data = _load_activities(force=True)
|
||||||
|
normal_count = sum(len(v) for v in data.get("normal", {}).values())
|
||||||
|
evil_count = sum(len(v) for v in data.get("evil", {}).values())
|
||||||
|
logger.info(f"Force-reloaded activities: {normal_count} normal entries, {evil_count} evil entries")
|
||||||
|
return {"status": "ok", "normal_entries": normal_count, "evil_entries": evil_count}
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Manual Override — set / clear / release current activity
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@router.get("/activities/current")
|
||||||
|
def get_current_activity():
|
||||||
|
"""Return the bot's current activity and override status."""
|
||||||
|
from utils.activities import get_current_activity, is_manual_override_active
|
||||||
|
activity = get_current_activity()
|
||||||
|
override = is_manual_override_active()
|
||||||
|
result = {
|
||||||
|
"activity": activity, # dict or null
|
||||||
|
"manual_override": override,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/activities/current")
|
||||||
|
async def set_current_activity(request: Request):
|
||||||
|
"""Manually set the bot's activity (bypasses mood system for 30 min).
|
||||||
|
|
||||||
|
Body: {"type": "listening"|"playing"|"watching"|"competing"|"streaming",
|
||||||
|
"name": "...", "state": "..." (optional), "url": "..." (required for streaming)}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(status_code=400, content={"error": "Invalid JSON body"})
|
||||||
|
|
||||||
|
activity_type = data.get("type", "").lower().strip()
|
||||||
|
name = data.get("name", "").strip()
|
||||||
|
state = data.get("state") or None
|
||||||
|
url = data.get("url") or None
|
||||||
|
|
||||||
|
# Pre-validate before passing to activity module
|
||||||
|
if not activity_type:
|
||||||
|
return JSONResponse(status_code=400, content={"error": "'type' is required"})
|
||||||
|
if not name:
|
||||||
|
return JSONResponse(status_code=400, content={"error": "'name' is required"})
|
||||||
|
if len(name) > 128:
|
||||||
|
return JSONResponse(status_code=400, content={"error": f"'name' exceeds 128 characters ({len(name)})"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.activities import set_activity_manual
|
||||||
|
await set_activity_manual(activity_type, name, state=state, url=url)
|
||||||
|
return {"status": "ok", "activity": {"type": activity_type, "name": name, "state": state, "url": url}}
|
||||||
|
except ValueError as e:
|
||||||
|
return JSONResponse(status_code=400, content={"error": str(e)})
|
||||||
|
except RuntimeError as e:
|
||||||
|
return JSONResponse(status_code=503, content={"error": str(e)})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set manual activity: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"error": "Internal server error"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/activities/current")
|
||||||
|
async def clear_current_activity():
|
||||||
|
"""Manually clear the bot's activity (stays idle, override stays active)."""
|
||||||
|
try:
|
||||||
|
from utils.activities import clear_activity_manual
|
||||||
|
await clear_activity_manual()
|
||||||
|
return {"status": "ok", "activity": None, "manual_override": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to clear manual activity: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"error": "Internal server error"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/activities/current/auto")
|
||||||
|
async def release_to_auto():
|
||||||
|
"""Release manual override and return to automatic mood-based activity."""
|
||||||
|
try:
|
||||||
|
from utils.activities import release_manual_override
|
||||||
|
await release_manual_override()
|
||||||
|
return {"status": "ok", "manual_override": False}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to release manual override: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"error": "Internal server error"})
|
||||||
262
bot/routes/autonomous.py
Normal file
262
bot/routes/autonomous.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"""Autonomous action routes: V1, V2, per-server autonomous."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from server_manager import server_manager
|
||||||
|
from routes.models import CustomPromptRequest
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Autonomous V1 ==========
|
||||||
|
|
||||||
|
@router.post("/autonomous/general")
|
||||||
|
async def trigger_autonomous_general(guild_id: int = None):
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
if guild_id is not None:
|
||||||
|
from utils.autonomous import miku_say_something_general_for_server
|
||||||
|
globals.client.loop.create_task(miku_say_something_general_for_server(guild_id))
|
||||||
|
return {"status": "ok", "message": f"Autonomous general message queued for server {guild_id}"}
|
||||||
|
else:
|
||||||
|
from utils.autonomous import miku_say_something_general
|
||||||
|
globals.client.loop.create_task(miku_say_something_general())
|
||||||
|
return {"status": "ok", "message": "Autonomous general message queued for all servers"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/autonomous/engage")
|
||||||
|
async def trigger_autonomous_engage_user(
|
||||||
|
guild_id: int = None,
|
||||||
|
user_id: str = None,
|
||||||
|
engagement_type: str = None,
|
||||||
|
manual_trigger: str = "false"
|
||||||
|
):
|
||||||
|
manual_trigger_bool = manual_trigger.lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
if guild_id is not None:
|
||||||
|
from utils.autonomous import miku_engage_random_user_for_server
|
||||||
|
globals.client.loop.create_task(miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger_bool))
|
||||||
|
|
||||||
|
msg_parts = [f"Autonomous user engagement queued for server {guild_id}"]
|
||||||
|
if user_id:
|
||||||
|
msg_parts.append(f"targeting user {user_id}")
|
||||||
|
if engagement_type:
|
||||||
|
msg_parts.append(f"with {engagement_type} engagement")
|
||||||
|
if manual_trigger_bool:
|
||||||
|
msg_parts.append("(manual trigger - bypassing cooldown)")
|
||||||
|
|
||||||
|
return {"status": "ok", "message": " ".join(msg_parts)}
|
||||||
|
else:
|
||||||
|
from utils.autonomous import miku_engage_random_user
|
||||||
|
globals.client.loop.create_task(miku_engage_random_user(user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger_bool))
|
||||||
|
|
||||||
|
msg_parts = ["Autonomous user engagement queued for all servers"]
|
||||||
|
if user_id:
|
||||||
|
msg_parts.append(f"targeting user {user_id}")
|
||||||
|
if engagement_type:
|
||||||
|
msg_parts.append(f"with {engagement_type} engagement")
|
||||||
|
if manual_trigger_bool:
|
||||||
|
msg_parts.append("(manual trigger - bypassing cooldown)")
|
||||||
|
|
||||||
|
return {"status": "ok", "message": " ".join(msg_parts)}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/autonomous/tweet")
|
||||||
|
async def trigger_autonomous_tweet(guild_id: int = None, tweet_url: str = None):
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
if guild_id is not None:
|
||||||
|
from utils.autonomous import share_miku_tweet_for_server
|
||||||
|
globals.client.loop.create_task(share_miku_tweet_for_server(guild_id, tweet_url=tweet_url))
|
||||||
|
msg = f"Autonomous tweet sharing queued for server {guild_id}"
|
||||||
|
if tweet_url:
|
||||||
|
msg += f" with URL {tweet_url}"
|
||||||
|
return {"status": "ok", "message": msg}
|
||||||
|
else:
|
||||||
|
from utils.autonomous import share_miku_tweet
|
||||||
|
globals.client.loop.create_task(share_miku_tweet(tweet_url=tweet_url))
|
||||||
|
msg = "Autonomous tweet sharing queued for all servers"
|
||||||
|
if tweet_url:
|
||||||
|
msg += f" with URL {tweet_url}"
|
||||||
|
return {"status": "ok", "message": msg}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/autonomous/custom")
|
||||||
|
async def custom_autonomous_message(req: CustomPromptRequest, guild_id: int = None):
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
if guild_id is not None:
|
||||||
|
from utils.autonomous import handle_custom_prompt_for_server
|
||||||
|
globals.client.loop.create_task(handle_custom_prompt_for_server(guild_id, req.prompt))
|
||||||
|
return {"status": "ok", "message": f"Custom autonomous message queued for server {guild_id}"}
|
||||||
|
else:
|
||||||
|
from utils.autonomous import handle_custom_prompt
|
||||||
|
globals.client.loop.create_task(handle_custom_prompt(req.prompt))
|
||||||
|
return {"status": "ok", "message": "Custom autonomous message queued for all servers"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/autonomous/reaction")
|
||||||
|
async def trigger_autonomous_reaction(guild_id: int = None):
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
if guild_id is not None:
|
||||||
|
from utils.autonomous import miku_autonomous_reaction_for_server
|
||||||
|
globals.client.loop.create_task(miku_autonomous_reaction_for_server(guild_id, force=True))
|
||||||
|
return {"status": "ok", "message": f"Autonomous reaction queued for server {guild_id}"}
|
||||||
|
else:
|
||||||
|
from utils.autonomous import miku_autonomous_reaction
|
||||||
|
globals.client.loop.create_task(miku_autonomous_reaction(force=True))
|
||||||
|
return {"status": "ok", "message": "Autonomous reaction queued for all servers"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/autonomous/join-conversation")
|
||||||
|
async def trigger_detect_and_join_conversation(guild_id: int = None):
|
||||||
|
logger.debug(f"Join conversation endpoint called with guild_id={guild_id}")
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
if guild_id is not None:
|
||||||
|
logger.debug(f"Importing and calling miku_detect_and_join_conversation_for_server({guild_id}, force=True)")
|
||||||
|
from utils.autonomous import miku_detect_and_join_conversation_for_server
|
||||||
|
globals.client.loop.create_task(miku_detect_and_join_conversation_for_server(guild_id, force=True))
|
||||||
|
return {"status": "ok", "message": f"Detect and join conversation queued for server {guild_id}"}
|
||||||
|
else:
|
||||||
|
logger.debug(f"Importing and calling miku_detect_and_join_conversation() for all servers")
|
||||||
|
from utils.autonomous import miku_detect_and_join_conversation
|
||||||
|
globals.client.loop.create_task(miku_detect_and_join_conversation(force=True))
|
||||||
|
return {"status": "ok", "message": "Detect and join conversation queued for all servers"}
|
||||||
|
else:
|
||||||
|
logger.error(f"Bot not ready: client={globals.client}, loop={globals.client.loop if globals.client else None}")
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Per-Server Autonomous ==========
|
||||||
|
|
||||||
|
@router.post("/servers/{guild_id}/autonomous/general")
|
||||||
|
async def trigger_autonomous_general_for_server(guild_id: int):
|
||||||
|
"""Trigger autonomous general message for a specific server"""
|
||||||
|
from utils.autonomous import miku_say_something_general_for_server
|
||||||
|
try:
|
||||||
|
await miku_say_something_general_for_server(guild_id)
|
||||||
|
return {"status": "ok", "message": f"Autonomous general message triggered for server {guild_id}"}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to trigger autonomous message: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/{guild_id}/autonomous/engage")
|
||||||
|
async def trigger_autonomous_engage_for_server(
|
||||||
|
guild_id: int,
|
||||||
|
user_id: str = None,
|
||||||
|
engagement_type: str = None,
|
||||||
|
manual_trigger: str = "false"
|
||||||
|
):
|
||||||
|
"""Trigger autonomous user engagement for a specific server"""
|
||||||
|
manual_trigger_bool = manual_trigger.lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
|
from utils.autonomous import miku_engage_random_user_for_server
|
||||||
|
try:
|
||||||
|
await miku_engage_random_user_for_server(guild_id, user_id=user_id, engagement_type=engagement_type, manual_trigger=manual_trigger_bool)
|
||||||
|
|
||||||
|
msg_parts = [f"Autonomous user engagement triggered for server {guild_id}"]
|
||||||
|
if user_id:
|
||||||
|
msg_parts.append(f"targeting user {user_id}")
|
||||||
|
if engagement_type:
|
||||||
|
msg_parts.append(f"with {engagement_type} engagement")
|
||||||
|
if manual_trigger_bool:
|
||||||
|
msg_parts.append("(manual trigger - bypassing cooldown)")
|
||||||
|
|
||||||
|
return {"status": "ok", "message": " ".join(msg_parts)}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to trigger user engagement: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/{guild_id}/autonomous/custom")
|
||||||
|
async def custom_autonomous_message_for_server(guild_id: int, req: CustomPromptRequest):
|
||||||
|
"""Send custom autonomous message to a specific server"""
|
||||||
|
from utils.autonomous import handle_custom_prompt_for_server
|
||||||
|
try:
|
||||||
|
success = await handle_custom_prompt_for_server(guild_id, req.prompt)
|
||||||
|
if success:
|
||||||
|
return {"status": "ok", "message": f"Custom autonomous message sent to server {guild_id}"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to send custom message to server {guild_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/{guild_id}/autonomous/tweet")
|
||||||
|
async def trigger_autonomous_tweet_for_server(guild_id: int):
|
||||||
|
"""Trigger autonomous tweet sharing for a specific server"""
|
||||||
|
from utils.autonomous import share_miku_tweet_for_server
|
||||||
|
try:
|
||||||
|
await share_miku_tweet_for_server(guild_id)
|
||||||
|
return {"status": "ok", "message": f"Autonomous tweet sharing triggered for server {guild_id}"}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to trigger tweet sharing: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Autonomous V2 ==========
|
||||||
|
|
||||||
|
@router.get("/autonomous/v2/stats/{guild_id}")
|
||||||
|
async def get_v2_stats(guild_id: int):
|
||||||
|
"""Get current V2 social stats for a server"""
|
||||||
|
try:
|
||||||
|
from utils.autonomous_v2_integration import get_v2_stats_for_server
|
||||||
|
stats = get_v2_stats_for_server(guild_id)
|
||||||
|
return {"status": "ok", "guild_id": guild_id, "stats": stats}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/autonomous/v2/check/{guild_id}")
|
||||||
|
async def manual_v2_check(guild_id: int):
|
||||||
|
"""Manually trigger a V2 context check"""
|
||||||
|
try:
|
||||||
|
from utils.autonomous_v2_integration import manual_trigger_v2_check
|
||||||
|
|
||||||
|
if not globals.client:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
result = await manual_trigger_v2_check(guild_id, globals.client)
|
||||||
|
|
||||||
|
if isinstance(result, str):
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result})
|
||||||
|
|
||||||
|
return {"status": "ok", "guild_id": guild_id, "analysis": result}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/autonomous/v2/status")
|
||||||
|
async def get_v2_status():
|
||||||
|
"""Get V2 system status for all servers"""
|
||||||
|
try:
|
||||||
|
from utils.autonomous_v2 import autonomous_system_v2
|
||||||
|
|
||||||
|
status = {}
|
||||||
|
for guild_id in server_manager.servers:
|
||||||
|
server_config = server_manager.get_server_config(guild_id)
|
||||||
|
if server_config:
|
||||||
|
stats = autonomous_system_v2.get_stats(guild_id)
|
||||||
|
status[str(guild_id)] = {
|
||||||
|
"server_name": server_config.guild_name,
|
||||||
|
"loop_running": autonomous_system_v2.running_loops.get(guild_id, False),
|
||||||
|
"action_urgency": f"{stats.get_action_urgency():.2f}",
|
||||||
|
"loneliness": f"{stats.loneliness:.2f}",
|
||||||
|
"boredom": f"{stats.boredom:.2f}",
|
||||||
|
"excitement": f"{stats.excitement:.2f}",
|
||||||
|
"chattiness": f"{stats.chattiness:.2f}",
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"status": "ok", "servers": status}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
293
bot/routes/bipolar_mode.py
Normal file
293
bot/routes/bipolar_mode.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""Bipolar mode routes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from routes.models import BipolarTriggerRequest
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bipolar-mode")
|
||||||
|
def get_bipolar_mode_status():
|
||||||
|
"""Get current bipolar mode status"""
|
||||||
|
from utils.bipolar_mode import is_bipolar_mode, is_argument_in_progress
|
||||||
|
|
||||||
|
# Get any active arguments
|
||||||
|
active_arguments = {}
|
||||||
|
for channel_id, data in globals.BIPOLAR_ARGUMENT_IN_PROGRESS.items():
|
||||||
|
if data.get("active"):
|
||||||
|
active_arguments[channel_id] = data
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bipolar_mode": is_bipolar_mode(),
|
||||||
|
"evil_mode": globals.EVIL_MODE,
|
||||||
|
"active_arguments": active_arguments,
|
||||||
|
"webhooks_configured": len(globals.BIPOLAR_WEBHOOKS)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bipolar-mode/enable")
|
||||||
|
def enable_bipolar_mode():
|
||||||
|
"""Enable bipolar mode"""
|
||||||
|
from utils.bipolar_mode import enable_bipolar_mode as _enable
|
||||||
|
|
||||||
|
if globals.BIPOLAR_MODE:
|
||||||
|
return {"status": "ok", "message": "Bipolar mode is already enabled", "bipolar_mode": True}
|
||||||
|
|
||||||
|
_enable()
|
||||||
|
|
||||||
|
# Persist to config manager
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set("runtime.bipolar_mode.enabled", True, persist=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist bipolar mode enable to config: {e}")
|
||||||
|
|
||||||
|
return {"status": "ok", "message": "Bipolar mode enabled", "bipolar_mode": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bipolar-mode/disable")
|
||||||
|
def disable_bipolar_mode():
|
||||||
|
"""Disable bipolar mode"""
|
||||||
|
from utils.bipolar_mode import disable_bipolar_mode as _disable, cleanup_webhooks
|
||||||
|
|
||||||
|
if not globals.BIPOLAR_MODE:
|
||||||
|
return {"status": "ok", "message": "Bipolar mode is already disabled", "bipolar_mode": False}
|
||||||
|
|
||||||
|
_disable()
|
||||||
|
|
||||||
|
# Persist to config manager
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set("runtime.bipolar_mode.enabled", False, persist=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist bipolar mode disable to config: {e}")
|
||||||
|
|
||||||
|
# Optionally cleanup webhooks in background
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
globals.client.loop.create_task(cleanup_webhooks(globals.client))
|
||||||
|
|
||||||
|
return {"status": "ok", "message": "Bipolar mode disabled", "bipolar_mode": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bipolar-mode/toggle")
|
||||||
|
def toggle_bipolar_mode():
|
||||||
|
"""Toggle bipolar mode on/off"""
|
||||||
|
from utils.bipolar_mode import toggle_bipolar_mode as _toggle, cleanup_webhooks
|
||||||
|
|
||||||
|
new_state = _toggle()
|
||||||
|
|
||||||
|
# If disabled, cleanup webhooks
|
||||||
|
if not new_state:
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
globals.client.loop.create_task(cleanup_webhooks(globals.client))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Bipolar mode {'enabled' if new_state else 'disabled'}",
|
||||||
|
"bipolar_mode": new_state
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bipolar-mode/trigger-argument")
|
||||||
|
def trigger_argument(data: BipolarTriggerRequest):
|
||||||
|
"""Manually trigger an argument in a specific channel
|
||||||
|
|
||||||
|
If message_id is provided, the argument will start from that message.
|
||||||
|
The opposite persona will respond to it.
|
||||||
|
"""
|
||||||
|
from utils.bipolar_mode import force_trigger_argument, force_trigger_argument_from_message_id, is_bipolar_mode, is_argument_in_progress
|
||||||
|
|
||||||
|
# Parse IDs from strings
|
||||||
|
try:
|
||||||
|
channel_id = int(data.channel_id)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid channel ID format"})
|
||||||
|
|
||||||
|
message_id = None
|
||||||
|
if data.message_id:
|
||||||
|
try:
|
||||||
|
message_id = int(data.message_id)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid message ID format"})
|
||||||
|
|
||||||
|
if not is_bipolar_mode():
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Bipolar mode is not enabled"})
|
||||||
|
|
||||||
|
if is_argument_in_progress(channel_id):
|
||||||
|
return JSONResponse(status_code=409, content={"status": "error", "message": "An argument is already in progress in this channel"})
|
||||||
|
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
|
||||||
|
|
||||||
|
# If message_id is provided, use the message-based trigger
|
||||||
|
if message_id:
|
||||||
|
async def trigger_from_message():
|
||||||
|
success, error = await force_trigger_argument_from_message_id(
|
||||||
|
channel_id, message_id, globals.client, data.context
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
logger.error(f"Failed to trigger argument from message: {error}")
|
||||||
|
|
||||||
|
globals.client.loop.create_task(trigger_from_message())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Argument triggered from message {message_id}",
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"message_id": message_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Otherwise, find the channel and trigger normally
|
||||||
|
channel = globals.client.get_channel(channel_id)
|
||||||
|
if not channel:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": f"Channel {channel_id} not found"})
|
||||||
|
|
||||||
|
# Trigger the argument — context doubles as the argument theme
|
||||||
|
globals.client.loop.create_task(force_trigger_argument(channel, globals.client, data.context))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Argument triggered in #{channel.name}",
|
||||||
|
"channel_id": channel_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bipolar-mode/trigger-dialogue")
|
||||||
|
def trigger_dialogue(data: dict):
|
||||||
|
"""Manually trigger a persona dialogue from a message
|
||||||
|
|
||||||
|
Forces the opposite persona to start a dialogue (bypasses the interjection check).
|
||||||
|
"""
|
||||||
|
from utils.persona_dialogue import get_dialogue_manager
|
||||||
|
from utils.bipolar_mode import is_bipolar_mode, is_argument_in_progress
|
||||||
|
|
||||||
|
message_id_str = data.get("message_id")
|
||||||
|
if not message_id_str:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Message ID is required"})
|
||||||
|
|
||||||
|
# Parse message ID
|
||||||
|
try:
|
||||||
|
message_id = int(message_id_str)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid message ID format"})
|
||||||
|
|
||||||
|
if not is_bipolar_mode():
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Bipolar mode is not enabled"})
|
||||||
|
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
|
||||||
|
|
||||||
|
async def trigger_dialogue_task():
|
||||||
|
try:
|
||||||
|
# Fetch the message
|
||||||
|
message = None
|
||||||
|
for channel in globals.client.get_all_channels():
|
||||||
|
if hasattr(channel, 'fetch_message'):
|
||||||
|
try:
|
||||||
|
message = await channel.fetch_message(message_id)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not message:
|
||||||
|
logger.error(f"Message {message_id} not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if there's already an argument or dialogue in progress
|
||||||
|
dialogue_manager = get_dialogue_manager()
|
||||||
|
if dialogue_manager.is_dialogue_active(message.channel.id):
|
||||||
|
logger.error(f"Dialogue already active in channel {message.channel.id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_argument_in_progress(message.channel.id):
|
||||||
|
logger.error(f"Argument already in progress in channel {message.channel.id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine current persona from the message author
|
||||||
|
if message.webhook_id:
|
||||||
|
# It's a webhook message, need to determine which persona
|
||||||
|
current_persona = "evil" if globals.EVIL_MODE else "miku"
|
||||||
|
elif message.author.id == globals.client.user.id:
|
||||||
|
# It's the bot's message
|
||||||
|
current_persona = "evil" if globals.EVIL_MODE else "miku"
|
||||||
|
else:
|
||||||
|
# User message - can't trigger dialogue from user messages
|
||||||
|
logger.error(f"Cannot trigger dialogue from user message")
|
||||||
|
return
|
||||||
|
|
||||||
|
opposite_persona = "evil" if current_persona == "miku" else "miku"
|
||||||
|
|
||||||
|
logger.info(f"[Manual Trigger] Forcing {opposite_persona} to start dialogue on message {message_id}")
|
||||||
|
|
||||||
|
# Force start the dialogue (bypass interjection check)
|
||||||
|
dialogue_manager.start_dialogue(message.channel.id)
|
||||||
|
asyncio.create_task(
|
||||||
|
dialogue_manager.handle_dialogue_turn(
|
||||||
|
message.channel,
|
||||||
|
opposite_persona,
|
||||||
|
trigger_reason="manual_trigger"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error triggering dialogue: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
globals.client.loop.create_task(trigger_dialogue_task())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Dialogue triggered for message {message_id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bipolar-mode/scoreboard")
|
||||||
|
def get_bipolar_scoreboard():
|
||||||
|
"""Get the bipolar mode argument scoreboard"""
|
||||||
|
from utils.bipolar_mode import load_scoreboard, get_scoreboard_summary
|
||||||
|
|
||||||
|
scoreboard = load_scoreboard()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"scoreboard": {
|
||||||
|
"miku_wins": scoreboard.get("miku", 0),
|
||||||
|
"evil_wins": scoreboard.get("evil", 0),
|
||||||
|
"total_arguments": scoreboard.get("miku", 0) + scoreboard.get("evil", 0),
|
||||||
|
"history": scoreboard.get("history", [])[-10:] # Last 10 results
|
||||||
|
},
|
||||||
|
"summary": get_scoreboard_summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bipolar-mode/cleanup-webhooks")
|
||||||
|
def cleanup_bipolar_webhooks():
|
||||||
|
"""Cleanup all bipolar webhooks from all servers"""
|
||||||
|
from utils.bipolar_mode import cleanup_webhooks
|
||||||
|
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
|
||||||
|
|
||||||
|
globals.client.loop.create_task(cleanup_webhooks(globals.client))
|
||||||
|
return {"status": "ok", "message": "Webhook cleanup started"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/bipolar-mode/arguments")
|
||||||
|
def get_active_arguments():
|
||||||
|
"""Get all active arguments"""
|
||||||
|
active = {}
|
||||||
|
for channel_id, data in globals.BIPOLAR_ARGUMENT_IN_PROGRESS.items():
|
||||||
|
if data.get("active"):
|
||||||
|
channel = globals.client.get_channel(channel_id) if globals.client else None
|
||||||
|
active[channel_id] = {
|
||||||
|
**data,
|
||||||
|
"channel_name": channel.name if channel else "Unknown"
|
||||||
|
}
|
||||||
|
return {"active_arguments": active}
|
||||||
53
bot/routes/bot_actions.py
Normal file
53
bot/routes/bot_actions.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Core bot action routes: conversation reset, sleep, wake, bedtime."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from commands.actions import (
|
||||||
|
force_sleep,
|
||||||
|
wake_up,
|
||||||
|
reset_conversation,
|
||||||
|
)
|
||||||
|
from routes.models import ConversationResetRequest
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/conversation/reset")
|
||||||
|
def reset_convo(data: ConversationResetRequest):
|
||||||
|
reset_conversation(data.user_id)
|
||||||
|
return {"status": "ok", "message": "Conversation reset"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sleep")
|
||||||
|
async def force_sleep_endpoint():
|
||||||
|
await force_sleep()
|
||||||
|
return {"status": "ok", "message": "Miku is now sleeping"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wake")
|
||||||
|
async def wake_up_endpoint():
|
||||||
|
await wake_up()
|
||||||
|
return {"status": "ok", "message": "Miku is now awake"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bedtime")
|
||||||
|
async def bedtime_endpoint(guild_id: int = None):
|
||||||
|
# If guild_id is provided, send bedtime reminder only to that server
|
||||||
|
# If no guild_id, send to all servers (legacy behavior)
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
if guild_id is not None:
|
||||||
|
# Send to specific server only
|
||||||
|
from utils.scheduled import send_bedtime_reminder_for_server
|
||||||
|
globals.client.loop.create_task(send_bedtime_reminder_for_server(guild_id, globals.client))
|
||||||
|
return {"status": "ok", "message": f"Bedtime reminder queued for server {guild_id}"}
|
||||||
|
else:
|
||||||
|
# Send to all servers (legacy behavior)
|
||||||
|
from utils.scheduled import send_bedtime_now
|
||||||
|
globals.client.loop.create_task(send_bedtime_now())
|
||||||
|
return {"status": "ok", "message": "Bedtime reminder queued for all servers"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
192
bot/routes/chat.py
Normal file
192
bot/routes/chat.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""Chat streaming endpoint (SSE) with LLM integration."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import aiohttp
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import globals
|
||||||
|
from routes.models import ChatMessage
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_gpu_url() -> str:
|
||||||
|
"""Get the llama-swap URL based on current GPU preference."""
|
||||||
|
if globals.PREFER_AMD_GPU:
|
||||||
|
return globals.LLAMA_AMD_URL
|
||||||
|
return globals.LLAMA_URL
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/stream")
|
||||||
|
async def chat_stream(chat_msg: ChatMessage):
|
||||||
|
"""
|
||||||
|
Stream chat responses from LLM with SSE (Server-Sent Events).
|
||||||
|
Supports both text and vision models with optional system prompts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def generate_stream():
|
||||||
|
try:
|
||||||
|
# Determine which model to use
|
||||||
|
model = globals.TEXT_MODEL if chat_msg.model_type == "text" else globals.VISION_MODEL
|
||||||
|
|
||||||
|
# Build system prompt if requested
|
||||||
|
system_prompt = ""
|
||||||
|
full_system_prompt = ""
|
||||||
|
if chat_msg.use_system_prompt:
|
||||||
|
if chat_msg.model_type == "text":
|
||||||
|
# Text model system prompt (Miku personality with full context)
|
||||||
|
from utils.context_manager import get_context_for_response_type
|
||||||
|
from utils.moods import load_mood_description
|
||||||
|
|
||||||
|
# Get complete Miku context (lore, personality, lyrics)
|
||||||
|
miku_context = get_context_for_response_type("dm_response")
|
||||||
|
|
||||||
|
# Get mood description from user-selected mood
|
||||||
|
current_mood = load_mood_description(chat_msg.mood)
|
||||||
|
|
||||||
|
# Base system prompt
|
||||||
|
system_prompt = """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 (1-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, not a real person
|
||||||
|
- She is a cheerful, energetic virtual idol with a playful personality
|
||||||
|
- 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 in her responses
|
||||||
|
- Her personality includes being excited about her music, performances, and fan creations
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
# Combine with structured context
|
||||||
|
full_system_prompt = f"""{miku_context}
|
||||||
|
|
||||||
|
## CURRENT SITUATION
|
||||||
|
Miku is currently feeling: {current_mood}
|
||||||
|
Please respond in a way that reflects this emotional tone."""
|
||||||
|
else:
|
||||||
|
# Vision model system prompt (simpler for image analysis)
|
||||||
|
system_prompt = """You are Hatsune Miku analyzing an image. Describe what you see naturally and enthusiastically as Miku would.
|
||||||
|
Be detailed but conversational. React to what you see with Miku's cheerful, playful personality."""
|
||||||
|
full_system_prompt = ""
|
||||||
|
|
||||||
|
# Build messages array
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
# Add system message if using system prompt
|
||||||
|
if system_prompt:
|
||||||
|
if full_system_prompt:
|
||||||
|
# Use combined prompt (base + context)
|
||||||
|
messages.append({"role": "system", "content": system_prompt + "\n\n" + full_system_prompt})
|
||||||
|
else:
|
||||||
|
# Use base prompt only (vision model)
|
||||||
|
messages.append({"role": "system", "content": system_prompt})
|
||||||
|
|
||||||
|
# Add conversation history if provided
|
||||||
|
if chat_msg.conversation_history:
|
||||||
|
messages.extend(chat_msg.conversation_history)
|
||||||
|
|
||||||
|
# Add user message
|
||||||
|
if chat_msg.model_type == "vision" and chat_msg.image_data:
|
||||||
|
# Vision model with image
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": chat_msg.message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/jpeg;base64,{chat_msg.image_data}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Text-only message
|
||||||
|
messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": chat_msg.message
|
||||||
|
})
|
||||||
|
|
||||||
|
# Prepare payload for streaming
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": True,
|
||||||
|
"temperature": 0.8,
|
||||||
|
"max_tokens": 512
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
# Get current GPU URL based on user selection
|
||||||
|
llama_url = get_current_gpu_url()
|
||||||
|
|
||||||
|
# Make streaming request to llama.cpp
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
f"{llama_url}/v1/chat/completions",
|
||||||
|
json=payload,
|
||||||
|
headers=headers
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
# Stream the response chunks
|
||||||
|
async for line in response.content:
|
||||||
|
line = line.decode('utf-8').strip()
|
||||||
|
if line.startswith('data: '):
|
||||||
|
data_str = line[6:] # Remove 'data: ' prefix
|
||||||
|
if data_str == '[DONE]':
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
data = json.loads(data_str)
|
||||||
|
if 'choices' in data and len(data['choices']) > 0:
|
||||||
|
delta = data['choices'][0].get('delta', {})
|
||||||
|
content = delta.get('content', '')
|
||||||
|
if content:
|
||||||
|
# Send SSE formatted data
|
||||||
|
yield f"data: {json.dumps({'content': content})}\n\n"
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Send completion signal
|
||||||
|
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||||
|
else:
|
||||||
|
error_text = await response.text()
|
||||||
|
error_msg = f"Error: {response.status} - {error_text}"
|
||||||
|
yield f"data: {json.dumps({'error': error_msg})}\n\n"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error in chat stream: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
yield f"data: {json.dumps({'error': error_msg})}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate_stream(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no" # Disable nginx buffering
|
||||||
|
}
|
||||||
|
)
|
||||||
184
bot/routes/config.py
Normal file
184
bot/routes/config.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""Configuration management routes: get/set/reset/validate config."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_full_config():
|
||||||
|
"""
|
||||||
|
Get full configuration including static, runtime, and state.
|
||||||
|
Useful for debugging and config display in UI.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
full_config = config_manager.get_full_config()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"config": full_config
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/static")
|
||||||
|
async def get_static_config():
|
||||||
|
"""
|
||||||
|
Get static configuration from config.yaml.
|
||||||
|
These are default values that can be overridden at runtime.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"config": config_manager.static_config
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get static config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/runtime")
|
||||||
|
async def get_runtime_config():
|
||||||
|
"""
|
||||||
|
Get runtime configuration overrides.
|
||||||
|
These are values changed via Web UI that override config.yaml.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"config": config_manager.runtime_config,
|
||||||
|
"path": str(config_manager.runtime_config_path)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get runtime config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/set")
|
||||||
|
async def set_config_value(request: Request):
|
||||||
|
"""
|
||||||
|
Set a configuration value with optional persistence.
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
"key_path": "discord.language_mode", // Dot-separated path
|
||||||
|
"value": "japanese",
|
||||||
|
"persist": true // Save to config_runtime.yaml
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
key_path = data.get("key_path")
|
||||||
|
value = data.get("value")
|
||||||
|
persist = data.get("persist", True)
|
||||||
|
|
||||||
|
if not key_path:
|
||||||
|
return JSONResponse(status_code=400, content={"success": False, "error": "key_path is required"})
|
||||||
|
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set(key_path, value, persist=persist)
|
||||||
|
|
||||||
|
# ── Sync globals for every runtime-relevant key path ──
|
||||||
|
_GLOBALS_SYNC = {
|
||||||
|
"discord.language_mode": ("LANGUAGE_MODE", str),
|
||||||
|
"autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool),
|
||||||
|
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
|
||||||
|
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
|
||||||
|
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
if key_path in _GLOBALS_SYNC:
|
||||||
|
attr, converter = _GLOBALS_SYNC[key_path]
|
||||||
|
setattr(globals, attr, converter(value))
|
||||||
|
elif key_path == "runtime.mood.dm_mood":
|
||||||
|
# DM mood needs description loaded alongside
|
||||||
|
if isinstance(value, str) and value in getattr(globals, "AVAILABLE_MOODS", []):
|
||||||
|
globals.DM_MOOD = value
|
||||||
|
try:
|
||||||
|
from utils.moods import load_mood_description
|
||||||
|
globals.DM_MOOD_DESCRIPTION = load_mood_description(value)
|
||||||
|
except Exception:
|
||||||
|
globals.DM_MOOD_DESCRIPTION = f"I'm feeling {value} today."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Set {key_path} = {value}",
|
||||||
|
"persisted": persist
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/reset")
|
||||||
|
async def reset_config(request: Request):
|
||||||
|
"""
|
||||||
|
Reset configuration to defaults.
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
"key_path": "discord.language_mode", // Optional: reset specific key
|
||||||
|
"persist": true // Remove from config_runtime.yaml
|
||||||
|
}
|
||||||
|
|
||||||
|
If key_path is omitted, resets all runtime config to defaults.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
key_path = data.get("key_path")
|
||||||
|
persist = data.get("persist", True)
|
||||||
|
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.reset_to_defaults(key_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Reset {key_path or 'all config'} to defaults"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to reset config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/validate")
|
||||||
|
async def validate_config_endpoint():
|
||||||
|
"""
|
||||||
|
Validate current configuration.
|
||||||
|
Returns list of errors if validation fails.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
is_valid, errors = config_manager.validate_config()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": is_valid,
|
||||||
|
"is_valid": is_valid,
|
||||||
|
"errors": errors
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to validate config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/state")
|
||||||
|
async def get_config_state():
|
||||||
|
"""
|
||||||
|
Get runtime state (not persisted config).
|
||||||
|
These are transient values like current mood, evil mode, etc.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"state": config_manager.runtime_state
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get config state: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
167
bot/routes/core.py
Normal file
167
bot/routes/core.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Core routes: index, logs, prompts, status, conversation."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
import globals
|
||||||
|
from server_manager import server_manager
|
||||||
|
from utils.conversation_history import conversation_history
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
def read_index():
|
||||||
|
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"}
|
||||||
|
return FileResponse("static/index.html", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs")
|
||||||
|
def get_logs():
|
||||||
|
try:
|
||||||
|
# Read last 100 lines of the log file
|
||||||
|
with open("/app/bot.log", "r", encoding="utf-8") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
last_100 = lines[-100:] if len(lines) >= 100 else lines
|
||||||
|
return "".join(last_100)
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error reading log file: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompt")
|
||||||
|
def get_last_prompt():
|
||||||
|
"""Legacy endpoint: returns the most recent fallback prompt (backward compat)."""
|
||||||
|
prompt_text = globals._get_last_fallback_prompt()
|
||||||
|
return {"prompt": prompt_text or "No prompt has been issued yet."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompt/cat")
|
||||||
|
def get_last_cat_prompt():
|
||||||
|
"""Legacy endpoint: returns the most recent Cat interaction (backward compat)."""
|
||||||
|
interaction = globals._get_last_cat_interaction()
|
||||||
|
if not interaction.get("full_prompt"):
|
||||||
|
return {"full_prompt": "No Cheshire Cat interaction has occurred yet.",
|
||||||
|
"response": "", "user": "", "mood": "", "timestamp": ""}
|
||||||
|
return interaction
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompts")
|
||||||
|
def get_prompt_history(source: str = None):
|
||||||
|
"""
|
||||||
|
Return the unified prompt history.
|
||||||
|
Optional query param ?source=cat or ?source=fallback to filter.
|
||||||
|
"""
|
||||||
|
history = list(globals.PROMPT_HISTORY)
|
||||||
|
if source and source in ("cat", "fallback"):
|
||||||
|
history = [e for e in history if e.get("source") == source]
|
||||||
|
return {"history": history}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/prompts/{prompt_id}")
|
||||||
|
def get_prompt_by_id(prompt_id: int):
|
||||||
|
"""Return a single prompt history entry by ID."""
|
||||||
|
for entry in globals.PROMPT_HISTORY:
|
||||||
|
if entry.get("id") == prompt_id:
|
||||||
|
return entry
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": f"Prompt #{prompt_id} not found"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def status():
|
||||||
|
# Get per-server mood summary
|
||||||
|
server_moods = {}
|
||||||
|
for guild_id in server_manager.servers:
|
||||||
|
mood_name, _ = server_manager.get_server_mood(guild_id)
|
||||||
|
server_moods[str(guild_id)] = mood_name
|
||||||
|
|
||||||
|
# Return evil mood when in evil mode
|
||||||
|
current_mood = globals.EVIL_DM_MOOD if globals.EVIL_MODE else globals.DM_MOOD
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "online",
|
||||||
|
"mood": current_mood,
|
||||||
|
"evil_mode": globals.EVIL_MODE,
|
||||||
|
"servers": len(server_manager.servers),
|
||||||
|
"active_schedulers": len(server_manager.schedulers),
|
||||||
|
"server_moods": server_moods
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/autonomous/stats")
|
||||||
|
def get_autonomous_stats():
|
||||||
|
"""Get autonomous engine stats for all servers"""
|
||||||
|
from utils.autonomous import autonomous_engine
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
for guild_id in server_manager.servers:
|
||||||
|
server_info = server_manager.servers[guild_id]
|
||||||
|
mood_name, _ = server_manager.get_server_mood(guild_id)
|
||||||
|
|
||||||
|
# Get context signals for this server
|
||||||
|
if guild_id in autonomous_engine.server_contexts:
|
||||||
|
ctx = autonomous_engine.server_contexts[guild_id]
|
||||||
|
|
||||||
|
# Get mood profile
|
||||||
|
mood_profile = autonomous_engine.mood_profiles.get(mood_name, {
|
||||||
|
"energy": 0.5,
|
||||||
|
"sociability": 0.5,
|
||||||
|
"impulsiveness": 0.5
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sanitize float values for JSON serialization (replace inf with large number)
|
||||||
|
time_since_action = ctx.time_since_last_action
|
||||||
|
if time_since_action == float('inf'):
|
||||||
|
time_since_action = 999999
|
||||||
|
|
||||||
|
time_since_interaction = ctx.time_since_last_interaction
|
||||||
|
if time_since_interaction == float('inf'):
|
||||||
|
time_since_interaction = 999999
|
||||||
|
|
||||||
|
stats[str(guild_id)] = {
|
||||||
|
"guild_name": server_info.guild_name,
|
||||||
|
"mood": mood_name,
|
||||||
|
"mood_profile": mood_profile,
|
||||||
|
"context": {
|
||||||
|
"messages_last_5min": ctx.messages_last_5min,
|
||||||
|
"messages_last_hour": ctx.messages_last_hour,
|
||||||
|
"unique_users_active": ctx.unique_users_active,
|
||||||
|
"conversation_momentum": round(ctx.conversation_momentum, 2),
|
||||||
|
"users_joined_recently": ctx.users_joined_recently,
|
||||||
|
"users_status_changed": ctx.users_status_changed,
|
||||||
|
"users_started_activity": ctx.users_started_activity,
|
||||||
|
"time_since_last_action": round(time_since_action, 1),
|
||||||
|
"time_since_last_interaction": round(time_since_interaction, 1),
|
||||||
|
"messages_since_last_appearance": ctx.messages_since_last_appearance,
|
||||||
|
"hour_of_day": ctx.hour_of_day,
|
||||||
|
"is_weekend": ctx.is_weekend,
|
||||||
|
"mood_energy_level": round(ctx.mood_energy_level, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Server not yet initialized in autonomous engine
|
||||||
|
mood_profile = autonomous_engine.mood_profiles.get(mood_name, {
|
||||||
|
"energy": 0.5,
|
||||||
|
"sociability": 0.5,
|
||||||
|
"impulsiveness": 0.5
|
||||||
|
})
|
||||||
|
|
||||||
|
stats[str(guild_id)] = {
|
||||||
|
"guild_name": server_info.guild_name,
|
||||||
|
"mood": mood_name,
|
||||||
|
"mood_profile": mood_profile,
|
||||||
|
"context": None
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"servers": stats}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/conversation/{user_id}")
|
||||||
|
def get_conversation(user_id: str):
|
||||||
|
"""Get conversation history for a user/channel (uses centralized ConversationHistory)."""
|
||||||
|
messages = conversation_history.get_recent_messages(user_id)
|
||||||
|
return {"conversation": [{"author": author, "content": content, "is_bot": is_bot} for author, content, is_bot in messages]}
|
||||||
468
bot/routes/dms.py
Normal file
468
bot/routes/dms.py
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
"""DM routes: custom prompt DMs, manual DMs, logging, blocking, analysis."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import List
|
||||||
|
from fastapi import APIRouter, UploadFile, File, Form
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import discord
|
||||||
|
import globals
|
||||||
|
from routes.models import CustomPromptRequest
|
||||||
|
from utils.dm_logger import dm_logger
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== DM Custom / Manual Send ==========
|
||||||
|
|
||||||
|
@router.post("/dm/{user_id}/custom")
|
||||||
|
async def send_custom_prompt_dm(user_id: str, req: CustomPromptRequest):
|
||||||
|
"""Send custom prompt via DM to a specific user"""
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
user = globals.client.get_user(user_id_int)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": f"User {user_id} not found"})
|
||||||
|
|
||||||
|
# Use the LLM query function for DM context
|
||||||
|
from utils.llm import query_llama
|
||||||
|
|
||||||
|
async def send_dm_custom_prompt():
|
||||||
|
try:
|
||||||
|
response = await query_llama(req.prompt, user_id=user_id, guild_id=None, response_type="dm_response")
|
||||||
|
await user.send(response)
|
||||||
|
logger.info(f"Custom DM prompt sent to user {user_id}: {req.prompt[:50]}...")
|
||||||
|
|
||||||
|
# Log to DM history
|
||||||
|
dm_logger.log_conversation(user_id, req.prompt, response)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send custom DM prompt to user {user_id}: {e}")
|
||||||
|
|
||||||
|
# Use create_task to avoid timeout context manager error
|
||||||
|
globals.client.loop.create_task(send_dm_custom_prompt())
|
||||||
|
return {"status": "ok", "message": f"Custom DM prompt queued for user {user_id}"}
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid user ID format"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dm/{user_id}/manual")
|
||||||
|
async def send_manual_message_dm(
|
||||||
|
user_id: str,
|
||||||
|
message: str = Form(...),
|
||||||
|
files: List[UploadFile] = File(default=[]),
|
||||||
|
reply_to_message_id: str = Form(None),
|
||||||
|
mention_author: bool = Form(True)
|
||||||
|
):
|
||||||
|
"""Send manual message via DM to a specific user"""
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
user = globals.client.get_user(user_id_int)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": f"User {user_id} not found"})
|
||||||
|
|
||||||
|
# Read file content immediately before the request closes
|
||||||
|
file_data = []
|
||||||
|
for file in files:
|
||||||
|
try:
|
||||||
|
file_content = await file.read()
|
||||||
|
file_data.append({
|
||||||
|
'filename': file.filename,
|
||||||
|
'content': file_content
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read file {file.filename}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to read file {file.filename}: {e}"})
|
||||||
|
|
||||||
|
async def send_dm_message_and_files():
|
||||||
|
try:
|
||||||
|
# Get the reference message if replying (must be done inside the task)
|
||||||
|
reference_message = None
|
||||||
|
if reply_to_message_id:
|
||||||
|
try:
|
||||||
|
dm_channel = user.dm_channel or await user.create_dm()
|
||||||
|
reference_message = await dm_channel.fetch_message(int(reply_to_message_id))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Could not fetch DM message {reply_to_message_id} for reply: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send the main message
|
||||||
|
if message.strip():
|
||||||
|
if reference_message:
|
||||||
|
await user.send(message, reference=reference_message, mention_author=mention_author)
|
||||||
|
logger.info(f"Manual DM reply message sent to user {user_id}")
|
||||||
|
else:
|
||||||
|
await user.send(message)
|
||||||
|
logger.info(f"Manual DM message sent to user {user_id}")
|
||||||
|
|
||||||
|
# Send files if any
|
||||||
|
for file_info in file_data:
|
||||||
|
try:
|
||||||
|
await user.send(file=discord.File(io.BytesIO(file_info['content']), filename=file_info['filename']))
|
||||||
|
logger.info(f"File {file_info['filename']} sent via DM to user {user_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send file {file_info['filename']} via DM: {e}")
|
||||||
|
|
||||||
|
# Log to DM history (user message = manual override trigger, miku response = the message sent)
|
||||||
|
dm_logger.log_conversation(user_id, "[Manual Override Trigger]", message, attachments=[f['filename'] for f in file_data])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send manual DM to user {user_id}: {e}")
|
||||||
|
|
||||||
|
# Use create_task to avoid timeout context manager error
|
||||||
|
globals.client.loop.create_task(send_dm_message_and_files())
|
||||||
|
return {"status": "ok", "message": f"Manual DM message queued for user {user_id}"}
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid user ID format"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== DM Logging Endpoints ==========
|
||||||
|
|
||||||
|
@router.get("/dms/users")
|
||||||
|
def get_dm_users():
|
||||||
|
"""Get summary of all users who have DMed the bot"""
|
||||||
|
try:
|
||||||
|
users = dm_logger.get_all_dm_users()
|
||||||
|
return {"status": "ok", "users": users}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get DM users: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dms/users/{user_id}")
|
||||||
|
def get_dm_user_conversation(user_id: str):
|
||||||
|
"""Get conversation summary for a specific user"""
|
||||||
|
try:
|
||||||
|
# Convert string user_id to int for internal processing
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
summary = dm_logger.get_user_conversation_summary(user_id_int)
|
||||||
|
return {"status": "ok", "summary": summary}
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get user conversation: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dms/users/{user_id}/conversations")
|
||||||
|
def get_dm_conversations(user_id: str, limit: int = 50):
|
||||||
|
"""Get recent conversations with a specific user"""
|
||||||
|
try:
|
||||||
|
# Convert string user_id to int for internal processing
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
logger.debug(f"Loading conversations for user {user_id_int}, limit: {limit}")
|
||||||
|
|
||||||
|
logs = dm_logger._load_user_logs(user_id_int)
|
||||||
|
logger.debug(f"Loaded logs for user {user_id_int}: {len(logs.get('conversations', []))} conversations")
|
||||||
|
|
||||||
|
conversations = logs["conversations"][-limit:] if limit > 0 else logs["conversations"]
|
||||||
|
|
||||||
|
# Convert message IDs to strings to prevent JavaScript precision loss
|
||||||
|
for conv in conversations:
|
||||||
|
if "message_id" in conv:
|
||||||
|
conv["message_id"] = str(conv["message_id"])
|
||||||
|
|
||||||
|
logger.debug(f"Returning {len(conversations)} conversations")
|
||||||
|
|
||||||
|
# Debug: Show message IDs being returned
|
||||||
|
for i, conv in enumerate(conversations):
|
||||||
|
msg_id = conv.get("message_id", "")
|
||||||
|
is_bot = conv.get("is_bot_message", False)
|
||||||
|
content_preview = conv.get("content", "")[:30] + "..." if conv.get("content", "") else "[No content]"
|
||||||
|
logger.debug(f"Conv {i}: id={msg_id} (type: {type(msg_id)}), is_bot={is_bot}, content='{content_preview}'")
|
||||||
|
|
||||||
|
return {"status": "ok", "conversations": conversations}
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get conversations for user {user_id}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get conversations: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dms/users/{user_id}/search")
|
||||||
|
def search_dm_conversations(user_id: str, query: str, limit: int = 10):
|
||||||
|
"""Search conversations with a specific user"""
|
||||||
|
try:
|
||||||
|
# Convert string user_id to int for internal processing
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
results = dm_logger.search_user_conversations(user_id_int, query, limit)
|
||||||
|
return {"status": "ok", "results": results}
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to search conversations: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dms/users/{user_id}/export")
|
||||||
|
def export_dm_conversation(user_id: str, format: str = "json"):
|
||||||
|
"""Export all conversations with a user"""
|
||||||
|
try:
|
||||||
|
# Convert string user_id to int for internal processing
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
export_path = dm_logger.export_user_conversation(user_id_int, format)
|
||||||
|
return {"status": "ok", "export_path": export_path, "format": format}
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to export conversation: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/dms/users/{user_id}")
|
||||||
|
def delete_dm_user_logs(user_id: str):
|
||||||
|
"""Delete all DM logs for a specific user"""
|
||||||
|
try:
|
||||||
|
# Convert string user_id to int for internal processing
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
log_file = dm_logger._get_user_log_file(user_id_int)
|
||||||
|
if os.path.exists(log_file):
|
||||||
|
os.remove(log_file)
|
||||||
|
return {"status": "ok", "message": f"Deleted DM logs for user {user_id}"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": f"No DM logs found for user {user_id}"})
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to delete DM logs: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== User Blocking & DM Management ==========
|
||||||
|
|
||||||
|
@router.get("/dms/blocked-users")
|
||||||
|
def get_blocked_users():
|
||||||
|
"""Get list of all blocked users"""
|
||||||
|
try:
|
||||||
|
blocked_users = dm_logger.get_blocked_users()
|
||||||
|
return {"status": "ok", "blocked_users": blocked_users}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get blocked users: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get blocked users: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dms/users/{user_id}/block")
|
||||||
|
def block_user(user_id: str):
|
||||||
|
"""Block a user from sending DMs to Miku"""
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
|
||||||
|
# Get username from DM logs if available
|
||||||
|
user_summary = dm_logger.get_user_conversation_summary(user_id_int)
|
||||||
|
username = user_summary.get("username", "Unknown")
|
||||||
|
|
||||||
|
success = dm_logger.block_user(user_id_int, username)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"User {user_id} ({username}) blocked")
|
||||||
|
return {"status": "ok", "message": f"User {username} has been blocked"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=409, content={"status": "error", "message": f"User {username} is already blocked"})
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to block user {user_id}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to block user: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dms/users/{user_id}/unblock")
|
||||||
|
def unblock_user(user_id: str):
|
||||||
|
"""Unblock a user"""
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
success = dm_logger.unblock_user(user_id_int)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"User {user_id} unblocked")
|
||||||
|
return {"status": "ok", "message": f"User has been unblocked"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=409, content={"status": "error", "message": f"User is not blocked"})
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to unblock user {user_id}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to unblock user: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dms/users/{user_id}/conversations/{conversation_id}/delete")
|
||||||
|
def delete_conversation(user_id: str, conversation_id: str):
|
||||||
|
"""Delete a specific conversation/message from both Discord and logs"""
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
|
||||||
|
# Queue the async deletion in the bot's event loop
|
||||||
|
async def do_delete():
|
||||||
|
return await dm_logger.delete_conversation(user_id_int, conversation_id)
|
||||||
|
|
||||||
|
globals.client.loop.create_task(do_delete())
|
||||||
|
|
||||||
|
# For now, return success immediately since we can't await in FastAPI sync endpoint
|
||||||
|
# The actual deletion happens asynchronously
|
||||||
|
logger.info(f"Queued deletion of conversation {conversation_id} for user {user_id}")
|
||||||
|
return {"status": "ok", "message": "Message deletion queued (will delete from both Discord and logs)"}
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to queue conversation deletion {conversation_id}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to delete conversation: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dms/users/{user_id}/conversations/delete-all")
|
||||||
|
def delete_all_conversations(user_id: str):
|
||||||
|
"""Delete all conversations with a user from both Discord and logs"""
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
|
||||||
|
# Queue the async bulk deletion in the bot's event loop
|
||||||
|
async def do_delete_all():
|
||||||
|
return await dm_logger.delete_all_conversations(user_id_int)
|
||||||
|
|
||||||
|
globals.client.loop.create_task(do_delete_all())
|
||||||
|
|
||||||
|
# Return success immediately since we can't await in FastAPI sync endpoint
|
||||||
|
logger.info(f"Queued bulk deletion of all conversations for user {user_id}")
|
||||||
|
return {"status": "ok", "message": "Bulk deletion queued (will delete all Miku messages from Discord and clear logs)"}
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to queue bulk conversation deletion for user {user_id}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to delete conversations: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dms/users/{user_id}/delete-completely")
|
||||||
|
def delete_user_completely(user_id: str):
|
||||||
|
"""Delete user's log file completely"""
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
success = dm_logger.delete_user_completely(user_id_int)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Completely deleted user {user_id}")
|
||||||
|
return {"status": "ok", "message": "User data deleted completely"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "No user data found"})
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to completely delete user {user_id}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to delete user: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== DM Interaction Analysis Endpoints ==========
|
||||||
|
|
||||||
|
@router.post("/dms/analysis/run")
|
||||||
|
def run_dm_analysis():
|
||||||
|
"""Manually trigger the daily DM interaction analysis"""
|
||||||
|
try:
|
||||||
|
from utils.dm_interaction_analyzer import dm_analyzer
|
||||||
|
|
||||||
|
if dm_analyzer is None:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "DM Analyzer not initialized. Set OWNER_USER_ID environment variable."})
|
||||||
|
|
||||||
|
# Schedule analysis in Discord's event loop
|
||||||
|
async def run_analysis():
|
||||||
|
await dm_analyzer.run_daily_analysis()
|
||||||
|
|
||||||
|
globals.client.loop.create_task(run_analysis())
|
||||||
|
|
||||||
|
return {"status": "ok", "message": "DM analysis started"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to run DM analysis: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to run DM analysis: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dms/users/{user_id}/analyze")
|
||||||
|
def analyze_user_interaction(user_id: str):
|
||||||
|
"""Analyze a specific user's interaction and optionally send report"""
|
||||||
|
try:
|
||||||
|
from utils.dm_interaction_analyzer import dm_analyzer
|
||||||
|
|
||||||
|
if dm_analyzer is None:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "DM Analyzer not initialized. Set OWNER_USER_ID environment variable."})
|
||||||
|
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
|
||||||
|
# Schedule analysis in Discord's event loop
|
||||||
|
async def run_analysis():
|
||||||
|
return await dm_analyzer.analyze_and_report(user_id_int)
|
||||||
|
|
||||||
|
globals.client.loop.create_task(run_analysis())
|
||||||
|
|
||||||
|
# Return immediately - the analysis will run in the background
|
||||||
|
return {"status": "ok", "message": f"Analysis started for user {user_id}", "reported": True}
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to analyze user {user_id}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to analyze user: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dms/analysis/reports")
|
||||||
|
def get_analysis_reports(limit: int = 20):
|
||||||
|
"""Get recent analysis reports"""
|
||||||
|
try:
|
||||||
|
from utils.dm_interaction_analyzer import REPORTS_DIR
|
||||||
|
|
||||||
|
if not os.path.exists(REPORTS_DIR):
|
||||||
|
return {"status": "ok", "reports": []}
|
||||||
|
|
||||||
|
reports = []
|
||||||
|
files = sorted([f for f in os.listdir(REPORTS_DIR) if f.endswith('.json') and f != 'reported_today.json'],
|
||||||
|
reverse=True)[:limit]
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(REPORTS_DIR, filename), 'r', encoding='utf-8') as f:
|
||||||
|
report = json.load(f)
|
||||||
|
report['filename'] = filename
|
||||||
|
reports.append(report)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load report {filename}: {e}")
|
||||||
|
|
||||||
|
return {"status": "ok", "reports": reports}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get reports: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get reports: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dms/analysis/reports/{user_id}")
|
||||||
|
def get_user_reports(user_id: str, limit: int = 10):
|
||||||
|
"""Get analysis reports for a specific user"""
|
||||||
|
try:
|
||||||
|
from utils.dm_interaction_analyzer import REPORTS_DIR
|
||||||
|
|
||||||
|
if not os.path.exists(REPORTS_DIR):
|
||||||
|
return {"status": "ok", "reports": []}
|
||||||
|
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
reports = []
|
||||||
|
files = sorted([f for f in os.listdir(REPORTS_DIR)
|
||||||
|
if f.startswith(f"{user_id}_") and f.endswith('.json')],
|
||||||
|
reverse=True)[:limit]
|
||||||
|
|
||||||
|
for filename in files:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(REPORTS_DIR, filename), 'r', encoding='utf-8') as f:
|
||||||
|
report = json.load(f)
|
||||||
|
report['filename'] = filename
|
||||||
|
reports.append(report)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load report {filename}: {e}")
|
||||||
|
|
||||||
|
return {"status": "ok", "reports": reports}
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid user ID format: {user_id}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get user reports: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to get user reports: {e}"})
|
||||||
111
bot/routes/evil_mode.py
Normal file
111
bot/routes/evil_mode.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""Evil mode routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from routes.models import EvilMoodSetRequest
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/evil-mode")
|
||||||
|
def get_evil_mode_status():
|
||||||
|
"""Get current evil mode status"""
|
||||||
|
from utils.evil_mode import is_evil_mode, get_current_evil_mood
|
||||||
|
evil_mode = is_evil_mode()
|
||||||
|
if evil_mode:
|
||||||
|
mood, mood_desc = get_current_evil_mood()
|
||||||
|
return {
|
||||||
|
"evil_mode": True,
|
||||||
|
"mood": mood,
|
||||||
|
"description": mood_desc,
|
||||||
|
"available_moods": globals.EVIL_AVAILABLE_MOODS
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"evil_mode": False,
|
||||||
|
"mood": None,
|
||||||
|
"description": None,
|
||||||
|
"available_moods": globals.EVIL_AVAILABLE_MOODS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/evil-mode/enable")
|
||||||
|
def enable_evil_mode():
|
||||||
|
"""Enable evil mode"""
|
||||||
|
from utils.evil_mode import apply_evil_mode_changes
|
||||||
|
|
||||||
|
if globals.EVIL_MODE:
|
||||||
|
return {"status": "ok", "message": "Evil mode is already enabled", "evil_mode": True}
|
||||||
|
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
globals.client.loop.create_task(apply_evil_mode_changes(globals.client))
|
||||||
|
return {"status": "ok", "message": "Evil mode enabled", "evil_mode": True}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/evil-mode/disable")
|
||||||
|
def disable_evil_mode():
|
||||||
|
"""Disable evil mode"""
|
||||||
|
from utils.evil_mode import revert_evil_mode_changes
|
||||||
|
|
||||||
|
if not globals.EVIL_MODE:
|
||||||
|
return {"status": "ok", "message": "Evil mode is already disabled", "evil_mode": False}
|
||||||
|
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
globals.client.loop.create_task(revert_evil_mode_changes(globals.client))
|
||||||
|
return {"status": "ok", "message": "Evil mode disabled", "evil_mode": False}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/evil-mode/toggle")
|
||||||
|
def toggle_evil_mode():
|
||||||
|
"""Toggle evil mode on/off"""
|
||||||
|
from utils.evil_mode import apply_evil_mode_changes, revert_evil_mode_changes
|
||||||
|
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Discord client not ready"})
|
||||||
|
|
||||||
|
if globals.EVIL_MODE:
|
||||||
|
globals.client.loop.create_task(revert_evil_mode_changes(globals.client))
|
||||||
|
return {"status": "ok", "message": "Evil mode disabled", "evil_mode": False}
|
||||||
|
else:
|
||||||
|
globals.client.loop.create_task(apply_evil_mode_changes(globals.client))
|
||||||
|
return {"status": "ok", "message": "Evil mode enabled", "evil_mode": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/evil-mode/mood")
|
||||||
|
def get_evil_mood():
|
||||||
|
"""Get current evil mood"""
|
||||||
|
from utils.evil_mode import get_current_evil_mood
|
||||||
|
mood, mood_desc = get_current_evil_mood()
|
||||||
|
return {
|
||||||
|
"mood": mood,
|
||||||
|
"description": mood_desc,
|
||||||
|
"available_moods": globals.EVIL_AVAILABLE_MOODS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/evil-mode/mood")
|
||||||
|
def set_evil_mood_endpoint(data: EvilMoodSetRequest):
|
||||||
|
"""Set evil mood"""
|
||||||
|
from utils.evil_mode import set_evil_mood, is_valid_evil_mood, update_all_evil_nicknames
|
||||||
|
|
||||||
|
if not is_valid_evil_mood(data.mood):
|
||||||
|
return JSONResponse(status_code=400, content={
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Mood '{data.mood}' not recognized. Available evil moods: {', '.join(globals.EVIL_AVAILABLE_MOODS)}"
|
||||||
|
})
|
||||||
|
|
||||||
|
success = set_evil_mood(data.mood)
|
||||||
|
if success:
|
||||||
|
# Update nicknames if evil mode is active
|
||||||
|
if globals.EVIL_MODE and globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
globals.client.loop.create_task(update_all_evil_nicknames(globals.client))
|
||||||
|
return {"status": "ok", "new_mood": data.mood}
|
||||||
|
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to set evil mood"})
|
||||||
81
bot/routes/figurines.py
Normal file
81
bot/routes/figurines.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Figurine subscriber and send routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Form
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from utils.figurine_notifier import (
|
||||||
|
load_subscribers as figurine_load_subscribers,
|
||||||
|
add_subscriber as figurine_add_subscriber,
|
||||||
|
remove_subscriber as figurine_remove_subscriber,
|
||||||
|
send_figurine_dm_to_all_subscribers,
|
||||||
|
send_figurine_dm_to_single_user,
|
||||||
|
)
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/figurines/subscribers")
|
||||||
|
async def get_figurine_subscribers():
|
||||||
|
subs = figurine_load_subscribers()
|
||||||
|
return {"subscribers": [str(uid) for uid in subs]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/figurines/subscribers")
|
||||||
|
async def add_figurine_subscriber(user_id: str = Form(...)):
|
||||||
|
try:
|
||||||
|
uid = int(user_id)
|
||||||
|
ok = figurine_add_subscriber(uid)
|
||||||
|
return {"status": "ok", "added": ok}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/figurines/subscribers/{user_id}")
|
||||||
|
async def delete_figurine_subscriber(user_id: str):
|
||||||
|
try:
|
||||||
|
uid = int(user_id)
|
||||||
|
ok = figurine_remove_subscriber(uid)
|
||||||
|
return {"status": "ok", "removed": ok}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/figurines/send_now")
|
||||||
|
async def figurines_send_now(tweet_url: str = Form(None)):
|
||||||
|
"""Trigger immediate figurine DM send to all subscribers, optionally with specific tweet URL"""
|
||||||
|
if globals.client and globals.client.loop and globals.client.loop.is_running():
|
||||||
|
logger.info(f"Sending figurine DMs to all subscribers, tweet_url: {tweet_url}")
|
||||||
|
globals.client.loop.create_task(send_figurine_dm_to_all_subscribers(globals.client, tweet_url=tweet_url))
|
||||||
|
return {"status": "ok", "message": "Figurine DMs queued"}
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/figurines/send_to_user")
|
||||||
|
async def figurines_send_to_user(user_id: str = Form(...), tweet_url: str = Form(None)):
|
||||||
|
"""Send figurine DM to a specific user, optionally with specific tweet URL"""
|
||||||
|
logger.debug(f"Received figurine send request - user_id: '{user_id}', tweet_url: '{tweet_url}'")
|
||||||
|
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
logger.error("Bot not ready")
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
logger.debug(f"Parsed user_id as {user_id_int}")
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"Invalid user ID: '{user_id}'")
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid user ID"})
|
||||||
|
|
||||||
|
# Clean up tweet URL if it's empty string
|
||||||
|
if tweet_url == "":
|
||||||
|
tweet_url = None
|
||||||
|
|
||||||
|
logger.info(f"Sending figurine DM to user {user_id_int}, tweet_url: {tweet_url}")
|
||||||
|
|
||||||
|
# Queue the DM send task in the bot's event loop
|
||||||
|
globals.client.loop.create_task(send_figurine_dm_to_single_user(globals.client, user_id_int, tweet_url=tweet_url))
|
||||||
|
|
||||||
|
return {"status": "ok", "message": f"Figurine DM to user {user_id} queued"}
|
||||||
39
bot/routes/gpu.py
Normal file
39
bot/routes/gpu.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""GPU selection routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gpu-status")
|
||||||
|
def get_gpu_status():
|
||||||
|
"""Get current GPU selection"""
|
||||||
|
from config_manager import config_manager
|
||||||
|
return {"gpu": config_manager.get_gpu()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/gpu-select")
|
||||||
|
async def select_gpu(request: Request):
|
||||||
|
"""Select which GPU to use for inference"""
|
||||||
|
data = await request.json()
|
||||||
|
gpu = data.get("gpu", "nvidia").lower()
|
||||||
|
|
||||||
|
if gpu not in ["nvidia", "amd"]:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid GPU selection. Must be 'nvidia' or 'amd'"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
success = config_manager.set_gpu(gpu)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"GPU Selection: Switched to {gpu.upper()} GPU")
|
||||||
|
return {"status": "ok", "message": f"Switched to {gpu.upper()} GPU", "gpu": gpu}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to save GPU state"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GPU Selection Error: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
108
bot/routes/image_generation.py
Normal file
108
bot/routes/image_generation.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Image generation routes: generate, status, test-detection, view."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/generate")
|
||||||
|
async def manual_image_generation(req: dict):
|
||||||
|
"""Manually trigger image generation for testing"""
|
||||||
|
try:
|
||||||
|
prompt = req.get("prompt", "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Prompt is required"})
|
||||||
|
|
||||||
|
from utils.image_generation import generate_image_with_comfyui
|
||||||
|
image_path = await generate_image_with_comfyui(prompt)
|
||||||
|
|
||||||
|
if image_path:
|
||||||
|
return {"status": "ok", "message": f"Image generated successfully", "image_path": image_path}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to generate image"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/image/status")
|
||||||
|
async def get_image_generation_status():
|
||||||
|
"""Get status of image generation system"""
|
||||||
|
try:
|
||||||
|
from utils.image_generation import check_comfyui_status
|
||||||
|
status = await check_comfyui_status()
|
||||||
|
return {"status": "ok", **status}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/test-detection")
|
||||||
|
async def test_image_detection(req: dict):
|
||||||
|
"""Test the natural language image detection system"""
|
||||||
|
try:
|
||||||
|
message = req.get("message", "").strip()
|
||||||
|
if not message:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Message is required"})
|
||||||
|
|
||||||
|
from utils.image_generation import detect_image_request
|
||||||
|
is_image_request, extracted_prompt = await detect_image_request(message)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"is_image_request": is_image_request,
|
||||||
|
"extracted_prompt": extracted_prompt,
|
||||||
|
"original_message": message
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/image/view/{filename}")
|
||||||
|
async def view_generated_image(filename: str):
|
||||||
|
"""Serve generated images from ComfyUI output directory"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"Image view request for: {filename}")
|
||||||
|
|
||||||
|
# Try multiple possible paths for ComfyUI output
|
||||||
|
possible_paths = [
|
||||||
|
f"/app/ComfyUI/output/{filename}",
|
||||||
|
f"/home/koko210Serve/ComfyUI/output/{filename}",
|
||||||
|
f"./ComfyUI/output/{filename}",
|
||||||
|
]
|
||||||
|
|
||||||
|
image_path = None
|
||||||
|
for path in possible_paths:
|
||||||
|
if os.path.exists(path):
|
||||||
|
image_path = path
|
||||||
|
logger.debug(f"Found image at: {path}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logger.debug(f"Not found at: {path}")
|
||||||
|
|
||||||
|
if not image_path:
|
||||||
|
logger.warning(f"Image not found anywhere: {filename}")
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": f"Image not found: {filename}"})
|
||||||
|
|
||||||
|
# Determine content type based on file extension
|
||||||
|
ext = filename.lower().split('.')[-1]
|
||||||
|
content_type = "image/png"
|
||||||
|
if ext == "jpg" or ext == "jpeg":
|
||||||
|
content_type = "image/jpeg"
|
||||||
|
elif ext == "gif":
|
||||||
|
content_type = "image/gif"
|
||||||
|
elif ext == "webp":
|
||||||
|
content_type = "image/webp"
|
||||||
|
|
||||||
|
logger.info(f"Serving image: {image_path} as {content_type}")
|
||||||
|
return FileResponse(image_path, media_type=content_type)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error serving image: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error serving image: {e}"})
|
||||||
75
bot/routes/language.py
Normal file
75
bot/routes/language.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Language mode routes."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/language")
|
||||||
|
def get_language_mode():
|
||||||
|
"""Get current language mode (english or japanese)"""
|
||||||
|
return {
|
||||||
|
"language_mode": globals.LANGUAGE_MODE,
|
||||||
|
"available_languages": ["english", "japanese"],
|
||||||
|
"current_model": globals.JAPANESE_TEXT_MODEL if globals.LANGUAGE_MODE == "japanese" else globals.TEXT_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/language/toggle")
|
||||||
|
def toggle_language_mode():
|
||||||
|
"""Toggle between English and Japanese modes"""
|
||||||
|
if globals.LANGUAGE_MODE == "english":
|
||||||
|
globals.LANGUAGE_MODE = "japanese"
|
||||||
|
new_mode = "japanese"
|
||||||
|
model_used = globals.JAPANESE_TEXT_MODEL
|
||||||
|
logger.info("Switched to Japanese mode (using Llama 3.1 Swallow)")
|
||||||
|
else:
|
||||||
|
globals.LANGUAGE_MODE = "english"
|
||||||
|
new_mode = "english"
|
||||||
|
model_used = globals.TEXT_MODEL
|
||||||
|
logger.info("Switched to English mode (using default model)")
|
||||||
|
|
||||||
|
# Persist via config manager
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set("discord.language_mode", new_mode, persist=True)
|
||||||
|
logger.info(f"💾 Language mode persisted to config_runtime.yaml")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist language mode: {e}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"language_mode": new_mode,
|
||||||
|
"model_now_using": model_used,
|
||||||
|
"message": f"Miku is now speaking in {new_mode.upper()}!"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/language/set")
|
||||||
|
def set_language_mode(language: str = "english"):
|
||||||
|
"""Set language mode to either 'english' or 'japanese'"""
|
||||||
|
if language.lower() not in ["english", "japanese"]:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Invalid language mode '{language}'. Use 'english' or 'japanese'."})
|
||||||
|
|
||||||
|
globals.LANGUAGE_MODE = language.lower()
|
||||||
|
model_used = globals.JAPANESE_TEXT_MODEL if language.lower() == "japanese" else globals.TEXT_MODEL
|
||||||
|
logger.info(f"Language mode set to {language.lower()} (using {model_used})")
|
||||||
|
|
||||||
|
# Persist so it survives restarts
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set("discord.language_mode", language.lower(), persist=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"language_mode": language.lower(),
|
||||||
|
"model_now_using": model_used,
|
||||||
|
"message": f"Miku is now speaking in {language.upper()}!"
|
||||||
|
}
|
||||||
174
bot/routes/logging_config.py
Normal file
174
bot/routes/logging_config.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""Logging configuration routes: get/set log levels, filters, components."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from routes.models import LogConfigUpdateRequest, LogFilterUpdateRequest
|
||||||
|
from utils.logger import get_logger, list_components, get_component_stats
|
||||||
|
from utils.log_config import (
|
||||||
|
load_config as load_log_config,
|
||||||
|
update_component, reload_all_loggers, update_api_filters,
|
||||||
|
reset_to_defaults, update_timestamp_format,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/log/config")
|
||||||
|
async def get_log_config():
|
||||||
|
"""Get current logging configuration."""
|
||||||
|
try:
|
||||||
|
config = load_log_config()
|
||||||
|
logger.debug("Log config requested")
|
||||||
|
return {"success": True, "config": config}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get log config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/log/config")
|
||||||
|
async def update_log_config(request: LogConfigUpdateRequest):
|
||||||
|
"""Update logging configuration."""
|
||||||
|
try:
|
||||||
|
if request.component:
|
||||||
|
success = update_component(
|
||||||
|
request.component,
|
||||||
|
enabled=request.enabled,
|
||||||
|
enabled_levels=request.enabled_levels
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to update component {request.component}"})
|
||||||
|
|
||||||
|
logger.info(f"Log config updated: component={request.component}, enabled_levels={request.enabled_levels}, enabled={request.enabled}")
|
||||||
|
return {"success": True, "message": "Configuration updated"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update log config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/log/components")
|
||||||
|
async def get_log_components():
|
||||||
|
"""Get list of all logging components with their descriptions."""
|
||||||
|
try:
|
||||||
|
components = list_components()
|
||||||
|
stats = get_component_stats()
|
||||||
|
logger.debug("Log components list requested")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"components": components,
|
||||||
|
"stats": stats
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get log components: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/log/reload")
|
||||||
|
async def reload_log_config():
|
||||||
|
"""Reload logging configuration from file."""
|
||||||
|
try:
|
||||||
|
success = reload_all_loggers()
|
||||||
|
if success:
|
||||||
|
logger.info("Log configuration reloaded")
|
||||||
|
return {"success": True, "message": "Configuration reloaded"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to reload configuration"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to reload log config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/log/filters")
|
||||||
|
async def update_log_filters(request: LogFilterUpdateRequest):
|
||||||
|
"""Update API request filtering configuration."""
|
||||||
|
try:
|
||||||
|
success = update_api_filters(
|
||||||
|
exclude_paths=request.exclude_paths,
|
||||||
|
exclude_status=request.exclude_status,
|
||||||
|
include_slow_requests=request.include_slow_requests,
|
||||||
|
slow_threshold_ms=request.slow_threshold_ms
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"API filters updated: {request.dict(exclude_none=True)}")
|
||||||
|
return {"success": True, "message": "Filters updated"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to update filters"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update filters: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/log/reset")
|
||||||
|
async def reset_log_config():
|
||||||
|
"""Reset logging configuration to defaults."""
|
||||||
|
try:
|
||||||
|
success = reset_to_defaults()
|
||||||
|
if success:
|
||||||
|
logger.info("Log configuration reset to defaults")
|
||||||
|
return {"success": True, "message": "Configuration reset to defaults"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to reset configuration"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to reset log config: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/log/global-level")
|
||||||
|
async def update_global_level_endpoint(level: str, enabled: bool):
|
||||||
|
"""Enable or disable a specific log level across all components."""
|
||||||
|
try:
|
||||||
|
from utils.log_config import update_global_level
|
||||||
|
success = update_global_level(level, enabled)
|
||||||
|
if success:
|
||||||
|
action = "enabled" if enabled else "disabled"
|
||||||
|
logger.info(f"Global level {level} {action} across all components")
|
||||||
|
return {"success": True, "message": f"Level {level} {action} globally"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to update global level {level}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update global level: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/log/timestamp-format")
|
||||||
|
async def update_timestamp_format_endpoint(format_type: str):
|
||||||
|
"""Update timestamp format for all log outputs."""
|
||||||
|
try:
|
||||||
|
success = update_timestamp_format(format_type)
|
||||||
|
if success:
|
||||||
|
logger.info(f"Timestamp format updated to: {format_type}")
|
||||||
|
return {"success": True, "message": f"Timestamp format set to: {format_type}"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=400, content={"success": False, "error": f"Invalid timestamp format: {format_type}"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update timestamp format: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/log/files/{component}")
|
||||||
|
async def get_log_file(component: str, lines: int = 100):
|
||||||
|
"""Get last N lines from a component's log file."""
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
log_dir = Path('/app/memory/logs')
|
||||||
|
log_file = log_dir / f'{component.replace(".", "_")}.log'
|
||||||
|
|
||||||
|
if not log_file.exists():
|
||||||
|
return JSONResponse(status_code=404, content={"success": False, "error": "Log file not found"})
|
||||||
|
|
||||||
|
with open(log_file, 'r', encoding='utf-8') as f:
|
||||||
|
all_lines = f.readlines()
|
||||||
|
last_lines = all_lines[-lines:] if len(all_lines) >= lines else all_lines
|
||||||
|
|
||||||
|
logger.debug(f"Log file requested: {component} ({lines} lines)")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"component": component,
|
||||||
|
"lines": last_lines,
|
||||||
|
"total_lines": len(all_lines)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read log file for {component}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
197
bot/routes/manual_send.py
Normal file
197
bot/routes/manual_send.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""Manual message sending routes + message reactions."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from typing import List
|
||||||
|
from fastapi import APIRouter, UploadFile, File, Form
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import discord
|
||||||
|
import globals
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/manual/send")
|
||||||
|
async def manual_send(
|
||||||
|
message: str = Form(...),
|
||||||
|
channel_id: str = Form(...),
|
||||||
|
files: List[UploadFile] = File(default=[]),
|
||||||
|
reply_to_message_id: str = Form(None),
|
||||||
|
mention_author: bool = Form(True)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
channel = globals.client.get_channel(int(channel_id))
|
||||||
|
if not channel:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "Channel not found"})
|
||||||
|
|
||||||
|
# Read file content immediately before the request closes
|
||||||
|
file_data = []
|
||||||
|
for file in files:
|
||||||
|
try:
|
||||||
|
file_content = await file.read()
|
||||||
|
file_data.append({
|
||||||
|
'filename': file.filename,
|
||||||
|
'content': file_content
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read file {file.filename}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to read file {file.filename}: {e}"})
|
||||||
|
|
||||||
|
async def send_message_and_files():
|
||||||
|
try:
|
||||||
|
reference_message = None
|
||||||
|
if reply_to_message_id:
|
||||||
|
try:
|
||||||
|
reference_message = await channel.fetch_message(int(reply_to_message_id))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Could not fetch message {reply_to_message_id} for reply: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if message.strip():
|
||||||
|
if reference_message:
|
||||||
|
await channel.send(message, reference=reference_message, mention_author=mention_author)
|
||||||
|
logger.info(f"Manual message sent as reply to #{channel.name}")
|
||||||
|
else:
|
||||||
|
await channel.send(message)
|
||||||
|
logger.info(f"Manual message sent to #{channel.name}")
|
||||||
|
|
||||||
|
for file_info in file_data:
|
||||||
|
try:
|
||||||
|
await channel.send(file=discord.File(io.BytesIO(file_info['content']), filename=file_info['filename']))
|
||||||
|
logger.info(f"File {file_info['filename']} sent to #{channel.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send file {file_info['filename']}: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send message: {e}")
|
||||||
|
|
||||||
|
globals.client.loop.create_task(send_message_and_files())
|
||||||
|
return {"status": "ok", "message": "Message and files queued for sending"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/manual/send-webhook")
|
||||||
|
async def manual_send_webhook(
|
||||||
|
message: str = Form(...),
|
||||||
|
channel_id: str = Form(...),
|
||||||
|
persona: str = Form("miku"),
|
||||||
|
files: List[UploadFile] = File(default=[]),
|
||||||
|
reply_to_message_id: str = Form(None),
|
||||||
|
mention_author: bool = Form(True)
|
||||||
|
):
|
||||||
|
"""Send a manual message via webhook as either Hatsune Miku or Evil Miku"""
|
||||||
|
try:
|
||||||
|
from utils.bipolar_mode import get_or_create_webhooks_for_channel, get_miku_display_name, get_evil_miku_display_name
|
||||||
|
|
||||||
|
channel = globals.client.get_channel(int(channel_id))
|
||||||
|
if not channel:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "Channel not found"})
|
||||||
|
|
||||||
|
if persona not in ["miku", "evil"]:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid persona. Must be 'miku' or 'evil'"})
|
||||||
|
|
||||||
|
file_data = []
|
||||||
|
for file in files:
|
||||||
|
try:
|
||||||
|
file_content = await file.read()
|
||||||
|
file_data.append({
|
||||||
|
'filename': file.filename,
|
||||||
|
'content': file_content
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read file {file.filename}: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to read file {file.filename}: {e}"})
|
||||||
|
|
||||||
|
async def send_webhook_message():
|
||||||
|
try:
|
||||||
|
webhooks = await get_or_create_webhooks_for_channel(channel)
|
||||||
|
if not webhooks:
|
||||||
|
logger.error(f"Failed to create webhooks for channel #{channel.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
webhook = webhooks["evil_miku"] if persona == "evil" else webhooks["miku"]
|
||||||
|
display_name = get_evil_miku_display_name() if persona == "evil" else get_miku_display_name()
|
||||||
|
|
||||||
|
discord_files = []
|
||||||
|
for file_info in file_data:
|
||||||
|
discord_files.append(discord.File(io.BytesIO(file_info['content']), filename=file_info['filename']))
|
||||||
|
|
||||||
|
from utils.bipolar_mode import get_persona_avatar_urls
|
||||||
|
avatar_urls = get_persona_avatar_urls()
|
||||||
|
avatar_url = avatar_urls.get("evil_miku") if persona == "evil" else avatar_urls.get("miku")
|
||||||
|
|
||||||
|
if discord_files:
|
||||||
|
await webhook.send(
|
||||||
|
content=message, username=display_name,
|
||||||
|
avatar_url=avatar_url, files=discord_files, wait=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await webhook.send(
|
||||||
|
content=message, username=display_name,
|
||||||
|
avatar_url=avatar_url, wait=True
|
||||||
|
)
|
||||||
|
|
||||||
|
persona_name = "Evil Miku" if persona == "evil" else "Hatsune Miku"
|
||||||
|
logger.info(f"Manual webhook message sent as {persona_name} to #{channel.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send webhook message: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
globals.client.loop.create_task(send_webhook_message())
|
||||||
|
return {"status": "ok", "message": f"Webhook message queued for sending as {persona}"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Error: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/messages/react")
|
||||||
|
async def add_reaction_to_message(
|
||||||
|
message_id: str = Form(...),
|
||||||
|
channel_id: str = Form(...),
|
||||||
|
emoji: str = Form(...)
|
||||||
|
):
|
||||||
|
"""Add a reaction to a specific message"""
|
||||||
|
try:
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg_id = int(message_id)
|
||||||
|
chan_id = int(channel_id)
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid message ID or channel ID format"})
|
||||||
|
|
||||||
|
channel = globals.client.get_channel(chan_id)
|
||||||
|
if not channel:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": f"Channel {channel_id} not found"})
|
||||||
|
|
||||||
|
async def add_reaction_task():
|
||||||
|
try:
|
||||||
|
message = await channel.fetch_message(msg_id)
|
||||||
|
await message.add_reaction(emoji)
|
||||||
|
logger.info(f"Added reaction {emoji} to message {msg_id} in channel #{channel.name}")
|
||||||
|
except discord.NotFound:
|
||||||
|
logger.error(f"Message {msg_id} not found in channel #{channel.name}")
|
||||||
|
except discord.Forbidden:
|
||||||
|
logger.error(f"Bot doesn't have permission to add reactions in channel #{channel.name}")
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
logger.error(f"Failed to add reaction: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error adding reaction: {e}")
|
||||||
|
|
||||||
|
globals.client.loop.create_task(add_reaction_task())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Reaction {emoji} queued for message {message_id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to add reaction: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to add reaction: {e}"})
|
||||||
194
bot/routes/memory.py
Normal file
194
bot/routes/memory.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"""Cheshire Cat memory management routes."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, Form
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from routes.models import MemoryDeleteRequest, MemoryEditRequest, MemoryCreateRequest
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/memory/status")
|
||||||
|
async def get_cat_memory_status():
|
||||||
|
"""Get Cheshire Cat connection status and feature flag."""
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
is_healthy = await cat_adapter.health_check()
|
||||||
|
return {
|
||||||
|
"enabled": globals.USE_CHESHIRE_CAT,
|
||||||
|
"healthy": is_healthy,
|
||||||
|
"url": globals.CHESHIRE_CAT_URL,
|
||||||
|
"circuit_breaker_active": cat_adapter._is_circuit_broken(),
|
||||||
|
"consecutive_failures": cat_adapter._consecutive_failures
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/memory/toggle")
|
||||||
|
async def toggle_cat_integration(enabled: bool = Form(...)):
|
||||||
|
"""Toggle Cheshire Cat integration on/off."""
|
||||||
|
globals.USE_CHESHIRE_CAT = enabled
|
||||||
|
logger.info(f"🐱 Cheshire Cat integration {'ENABLED' if enabled else 'DISABLED'}")
|
||||||
|
|
||||||
|
# Persist so it survives restarts
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set("memory.use_cheshire_cat", enabled, persist=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"enabled": globals.USE_CHESHIRE_CAT,
|
||||||
|
"message": f"Cheshire Cat {'enabled' if enabled else 'disabled'}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/memory/stats")
|
||||||
|
async def get_memory_stats():
|
||||||
|
"""Get memory collection statistics from Cheshire Cat (point counts per collection)."""
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
stats = await cat_adapter.get_memory_stats()
|
||||||
|
if stats is None:
|
||||||
|
return JSONResponse(status_code=502, content={"success": False, "error": "Could not reach Cheshire Cat"})
|
||||||
|
return {"success": True, "collections": stats.get("collections", [])}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/memory/facts")
|
||||||
|
async def get_memory_facts():
|
||||||
|
"""Get all declarative memory facts (learned knowledge about users)."""
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
facts = await cat_adapter.get_all_facts()
|
||||||
|
return {"success": True, "facts": facts, "count": len(facts)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/memory/episodic")
|
||||||
|
async def get_episodic_memories():
|
||||||
|
"""Get all episodic memories (conversation snippets)."""
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
result = await cat_adapter.get_memory_points(collection="episodic", limit=100)
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=502, content={"success": False, "error": "Could not reach Cheshire Cat"})
|
||||||
|
|
||||||
|
memories = []
|
||||||
|
for point in result.get("points", []):
|
||||||
|
payload = point.get("payload", {})
|
||||||
|
memories.append({
|
||||||
|
"id": point.get("id"),
|
||||||
|
"content": payload.get("page_content", ""),
|
||||||
|
"metadata": payload.get("metadata", {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"success": True, "memories": memories, "count": len(memories)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/memory/consolidate")
|
||||||
|
async def trigger_memory_consolidation():
|
||||||
|
"""Manually trigger memory consolidation (sleep consolidation process)."""
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
logger.info("🌙 Manual memory consolidation triggered via API")
|
||||||
|
result = await cat_adapter.trigger_consolidation()
|
||||||
|
if result is None:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": "Consolidation failed or timed out"})
|
||||||
|
return {"success": True, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/memory/delete")
|
||||||
|
async def delete_all_memories(request: MemoryDeleteRequest):
|
||||||
|
"""
|
||||||
|
Delete ALL of Miku's memories. Requires exact confirmation string.
|
||||||
|
|
||||||
|
The confirmation field must be exactly:
|
||||||
|
"Yes, I am deleting Miku's memories fully."
|
||||||
|
|
||||||
|
This is destructive and irreversible.
|
||||||
|
"""
|
||||||
|
REQUIRED_CONFIRMATION = "Yes, I am deleting Miku's memories fully."
|
||||||
|
|
||||||
|
if request.confirmation != REQUIRED_CONFIRMATION:
|
||||||
|
logger.warning(f"Memory deletion rejected: wrong confirmation string")
|
||||||
|
return JSONResponse(status_code=400, content={
|
||||||
|
"success": False,
|
||||||
|
"error": "Confirmation string does not match. "
|
||||||
|
f"Expected exactly: \"{REQUIRED_CONFIRMATION}\""
|
||||||
|
})
|
||||||
|
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
logger.warning("⚠️ MEMORY DELETION CONFIRMED — wiping all memories!")
|
||||||
|
|
||||||
|
# Wipe vector memories (episodic + declarative)
|
||||||
|
wipe_success = await cat_adapter.wipe_all_memories()
|
||||||
|
|
||||||
|
# Also clear conversation history
|
||||||
|
history_success = await cat_adapter.wipe_conversation_history()
|
||||||
|
|
||||||
|
if wipe_success:
|
||||||
|
logger.warning("🗑️ All Miku memories have been deleted.")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "All memories have been permanently deleted.",
|
||||||
|
"vector_memory_wiped": wipe_success,
|
||||||
|
"conversation_history_cleared": history_success
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={
|
||||||
|
"success": False,
|
||||||
|
"error": "Failed to wipe memory collections. Check Cat connection."
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/memory/point/{collection}/{point_id}")
|
||||||
|
async def delete_single_memory_point(collection: str, point_id: str):
|
||||||
|
"""Delete a single memory point by collection and ID."""
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
success = await cat_adapter.delete_memory_point(collection, point_id)
|
||||||
|
if success:
|
||||||
|
return {"success": True, "deleted": point_id}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to delete point {point_id}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/memory/point/{collection}/{point_id}")
|
||||||
|
async def edit_memory_point(collection: str, point_id: str, request: MemoryEditRequest):
|
||||||
|
"""Edit an existing memory point's content and/or metadata."""
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
success = await cat_adapter.update_memory_point(
|
||||||
|
collection=collection,
|
||||||
|
point_id=point_id,
|
||||||
|
content=request.content,
|
||||||
|
metadata=request.metadata
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
return {"success": True, "updated": point_id}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to update point {point_id}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/memory/create")
|
||||||
|
async def create_memory_point(request: MemoryCreateRequest):
|
||||||
|
"""
|
||||||
|
Manually create a new memory (declarative fact or episodic memory).
|
||||||
|
|
||||||
|
For declarative facts, this allows you to teach Miku new knowledge.
|
||||||
|
For episodic memories, this allows you to inject conversation context.
|
||||||
|
"""
|
||||||
|
from utils.cat_client import cat_adapter
|
||||||
|
|
||||||
|
if request.collection not in ['declarative', 'episodic']:
|
||||||
|
return JSONResponse(status_code=400, content={"success": False, "error": "Collection must be 'declarative' or 'episodic'"})
|
||||||
|
|
||||||
|
# Create the memory point
|
||||||
|
result = await cat_adapter.create_memory_point(
|
||||||
|
collection=request.collection,
|
||||||
|
content=request.content,
|
||||||
|
user_id=request.user_id or "manual_admin",
|
||||||
|
source=request.source or "manual_web_ui",
|
||||||
|
metadata=request.metadata or {}
|
||||||
|
)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
return {"success": True, "point_id": result, "collection": request.collection}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to create memory point"})
|
||||||
100
bot/routes/models.py
Normal file
100
bot/routes/models.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Shared Pydantic request/response models used across route modules."""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MoodSetRequest(BaseModel):
|
||||||
|
mood: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationResetRequest(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class CustomPromptRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
class ServerConfigRequest(BaseModel):
|
||||||
|
guild_id: int
|
||||||
|
guild_name: str
|
||||||
|
autonomous_channel_id: int
|
||||||
|
autonomous_channel_name: str
|
||||||
|
bedtime_channel_ids: List[int] = None
|
||||||
|
enabled_features: List[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EvilMoodSetRequest(BaseModel):
|
||||||
|
mood: str
|
||||||
|
|
||||||
|
|
||||||
|
class LogConfigUpdateRequest(BaseModel):
|
||||||
|
component: Optional[str] = None
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
enabled_levels: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LogFilterUpdateRequest(BaseModel):
|
||||||
|
exclude_paths: Optional[List[str]] = None
|
||||||
|
exclude_status: Optional[List[int]] = None
|
||||||
|
include_slow_requests: Optional[bool] = True
|
||||||
|
slow_threshold_ms: Optional[int] = 1000
|
||||||
|
|
||||||
|
|
||||||
|
class BipolarTriggerRequest(BaseModel):
|
||||||
|
channel_id: str # String to handle large Discord IDs from JS
|
||||||
|
message_id: str = None # Optional: starting message ID (string)
|
||||||
|
context: str = "" # Optional: argument theme/context — tells them what to argue about
|
||||||
|
|
||||||
|
|
||||||
|
class ManualCropRequest(BaseModel):
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
|
||||||
|
class DescriptionUpdateRequest(BaseModel):
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumCropRequest(BaseModel):
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
|
||||||
|
class AlbumDescriptionRequest(BaseModel):
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDeleteRequest(BaseModel):
|
||||||
|
entry_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessage(BaseModel):
|
||||||
|
message: str
|
||||||
|
model_type: str = "text" # "text" or "vision"
|
||||||
|
use_system_prompt: bool = True
|
||||||
|
image_data: Optional[str] = None # Base64 encoded image for vision model
|
||||||
|
conversation_history: Optional[List[dict]] = None # Previous messages in conversation
|
||||||
|
mood: str = "neutral" # Miku's mood for this conversation
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryDeleteRequest(BaseModel):
|
||||||
|
confirmation: str
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryEditRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCreateRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
collection: str # 'declarative' or 'episodic'
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
metadata: Optional[dict] = None
|
||||||
193
bot/routes/mood.py
Normal file
193
bot/routes/mood.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""Mood management routes: DM mood, per-server mood, available moods, test mood."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from server_manager import server_manager
|
||||||
|
from routes.models import MoodSetRequest
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== DM Mood ==========
|
||||||
|
|
||||||
|
@router.get("/mood")
|
||||||
|
def get_current_mood():
|
||||||
|
return {"mood": globals.DM_MOOD, "description": globals.DM_MOOD_DESCRIPTION}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mood")
|
||||||
|
async def set_mood_endpoint(data: MoodSetRequest):
|
||||||
|
# This endpoint now operates on DM_MOOD
|
||||||
|
from utils.moods import MOOD_EMOJIS
|
||||||
|
if data.mood not in MOOD_EMOJIS:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Mood '{data.mood}' not recognized. Available moods: {', '.join(MOOD_EMOJIS.keys())}"})
|
||||||
|
|
||||||
|
# Update DM mood (DMs don't have nicknames, so no nickname update needed)
|
||||||
|
globals.DM_MOOD = data.mood
|
||||||
|
from utils.moods import load_mood_description
|
||||||
|
globals.DM_MOOD_DESCRIPTION = load_mood_description(data.mood)
|
||||||
|
|
||||||
|
# Persist to config manager
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set("runtime.mood.dm_mood", data.mood, persist=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist mood to config: {e}")
|
||||||
|
|
||||||
|
return {"status": "ok", "new_mood": data.mood}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mood/reset")
|
||||||
|
async def reset_mood_endpoint():
|
||||||
|
# Reset DM mood to neutral (DMs don't have nicknames, so no nickname update needed)
|
||||||
|
globals.DM_MOOD = "neutral"
|
||||||
|
from utils.moods import load_mood_description
|
||||||
|
globals.DM_MOOD_DESCRIPTION = load_mood_description("neutral")
|
||||||
|
|
||||||
|
# Persist to config manager
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set("runtime.mood.dm_mood", "neutral", persist=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist mood reset to config: {e}")
|
||||||
|
|
||||||
|
return {"status": "ok", "new_mood": "neutral"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mood/calm")
|
||||||
|
def calm_miku_endpoint():
|
||||||
|
# Calm DM mood to neutral (DMs don't have nicknames, so no nickname update needed)
|
||||||
|
globals.DM_MOOD = "neutral"
|
||||||
|
from utils.moods import load_mood_description
|
||||||
|
globals.DM_MOOD_DESCRIPTION = load_mood_description("neutral")
|
||||||
|
|
||||||
|
# Persist to config manager
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set("runtime.mood.dm_mood", "neutral", persist=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist mood calm to config: {e}")
|
||||||
|
|
||||||
|
return {"status": "ok", "message": "Miku has been calmed down"}
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Per-Server Mood ==========
|
||||||
|
|
||||||
|
@router.get("/servers/{guild_id}/mood")
|
||||||
|
def get_server_mood(guild_id: int):
|
||||||
|
"""Get current mood for a specific server"""
|
||||||
|
mood_name, mood_description = server_manager.get_server_mood(guild_id)
|
||||||
|
return {
|
||||||
|
"guild_id": guild_id,
|
||||||
|
"mood": mood_name,
|
||||||
|
"description": mood_description
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/{guild_id}/mood")
|
||||||
|
async def set_server_mood_endpoint(guild_id: int, data: MoodSetRequest):
|
||||||
|
"""Set mood for a specific server"""
|
||||||
|
|
||||||
|
# Check if server exists
|
||||||
|
if guild_id not in server_manager.servers:
|
||||||
|
logger.warning(f"Server {guild_id} not found in server_manager.servers")
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "Server not found"})
|
||||||
|
|
||||||
|
# Check if mood is valid
|
||||||
|
from utils.moods import MOOD_EMOJIS
|
||||||
|
if data.mood not in MOOD_EMOJIS:
|
||||||
|
logger.warning(f"Mood '{data.mood}' not found in MOOD_EMOJIS. Available moods: {list(MOOD_EMOJIS.keys())}")
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Mood '{data.mood}' not recognized. Available moods: {', '.join(MOOD_EMOJIS.keys())}"})
|
||||||
|
|
||||||
|
success = server_manager.set_server_mood(guild_id, data.mood)
|
||||||
|
logger.debug(f"Server mood set result: {success}")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Update the nickname for this server
|
||||||
|
from utils.moods import update_server_nickname
|
||||||
|
logger.debug(f"Updating nickname for server {guild_id}")
|
||||||
|
globals.client.loop.create_task(update_server_nickname(guild_id))
|
||||||
|
return {"status": "ok", "new_mood": data.mood, "guild_id": guild_id}
|
||||||
|
|
||||||
|
logger.warning(f"set_server_mood returned False for unknown reason")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to set server mood"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/{guild_id}/mood/reset")
|
||||||
|
async def reset_server_mood_endpoint(guild_id: int):
|
||||||
|
"""Reset mood to neutral for a specific server"""
|
||||||
|
logger.debug(f"Resetting mood for server {guild_id} to neutral")
|
||||||
|
|
||||||
|
# Check if server exists
|
||||||
|
if guild_id not in server_manager.servers:
|
||||||
|
logger.warning(f"Server {guild_id} not found in server_manager.servers")
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "Server not found"})
|
||||||
|
|
||||||
|
logger.debug(f"Server validation passed, calling set_server_mood")
|
||||||
|
success = server_manager.set_server_mood(guild_id, "neutral")
|
||||||
|
logger.debug(f"Server mood reset result: {success}")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Update the nickname for this server
|
||||||
|
from utils.moods import update_server_nickname
|
||||||
|
logger.debug(f"Updating nickname for server {guild_id}")
|
||||||
|
globals.client.loop.create_task(update_server_nickname(guild_id))
|
||||||
|
return {"status": "ok", "new_mood": "neutral", "guild_id": guild_id}
|
||||||
|
|
||||||
|
logger.warning(f"set_server_mood returned False for unknown reason")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to reset server mood"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/servers/{guild_id}/mood/state")
|
||||||
|
def get_server_mood_state(guild_id: int):
|
||||||
|
"""Get complete mood state for a specific server"""
|
||||||
|
mood_state = server_manager.get_server_mood_state(guild_id)
|
||||||
|
if mood_state:
|
||||||
|
return {"status": "ok", "guild_id": guild_id, "mood_state": mood_state}
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "Server not found"})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Misc Mood ==========
|
||||||
|
|
||||||
|
@router.get("/moods/available")
|
||||||
|
def get_available_moods():
|
||||||
|
"""Get list of all available moods"""
|
||||||
|
from utils.moods import MOOD_EMOJIS
|
||||||
|
return {"moods": list(MOOD_EMOJIS.keys())}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test/mood/{guild_id}")
|
||||||
|
async def test_mood_change(guild_id: int, data: MoodSetRequest):
|
||||||
|
"""Test endpoint for debugging mood changes"""
|
||||||
|
logger.debug(f"TEST: Testing mood change for server {guild_id} to {data.mood}")
|
||||||
|
|
||||||
|
# Check if server exists
|
||||||
|
if guild_id not in server_manager.servers:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": f"Server {guild_id} not found"})
|
||||||
|
|
||||||
|
server_config = server_manager.get_server_config(guild_id)
|
||||||
|
logger.debug(f"TEST: Server config found: {server_config.guild_name if server_config else 'None'}")
|
||||||
|
|
||||||
|
# Try to set mood
|
||||||
|
success = server_manager.set_server_mood(guild_id, data.mood)
|
||||||
|
logger.debug(f"TEST: Mood set result: {success}")
|
||||||
|
|
||||||
|
if success:
|
||||||
|
# Try to update nickname
|
||||||
|
from utils.moods import update_server_nickname
|
||||||
|
logger.debug(f"TEST: Attempting nickname update...")
|
||||||
|
try:
|
||||||
|
await update_server_nickname(guild_id)
|
||||||
|
logger.debug(f"TEST: Nickname update completed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TEST: Nickname update failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
return {"status": "ok", "message": f"Test mood change completed", "success": success}
|
||||||
|
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Mood change failed"})
|
||||||
527
bot/routes/profile_picture.py
Normal file
527
bot/routes/profile_picture.py
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
"""Profile picture routes: change, crop, album, role color."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import List
|
||||||
|
from fastapi import APIRouter, UploadFile, File, Form
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
import globals
|
||||||
|
from routes.models import (
|
||||||
|
ManualCropRequest, DescriptionUpdateRequest,
|
||||||
|
AlbumCropRequest, AlbumDescriptionRequest, BulkDeleteRequest,
|
||||||
|
)
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Profile Picture — Core ==========
|
||||||
|
|
||||||
|
@router.post("/profile-picture/change")
|
||||||
|
async def trigger_profile_picture_change(
|
||||||
|
guild_id: int = None,
|
||||||
|
file: UploadFile = File(None)
|
||||||
|
):
|
||||||
|
"""Change Miku's profile picture. If a file is provided, use it. Otherwise, search Danbooru."""
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
from server_manager import server_manager
|
||||||
|
|
||||||
|
mood = None
|
||||||
|
if guild_id is not None:
|
||||||
|
mood, _ = server_manager.get_server_mood(guild_id)
|
||||||
|
else:
|
||||||
|
mood = globals.DM_MOOD
|
||||||
|
|
||||||
|
custom_image_bytes = None
|
||||||
|
if file:
|
||||||
|
custom_image_bytes = await file.read()
|
||||||
|
logger.info(f"Received custom image upload ({len(custom_image_bytes)} bytes)")
|
||||||
|
|
||||||
|
result = await profile_picture_manager.change_profile_picture(
|
||||||
|
mood=mood, custom_image_bytes=custom_image_bytes, debug=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Profile picture changed successfully",
|
||||||
|
"source": result["source"],
|
||||||
|
"metadata": result.get("metadata", {})
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={
|
||||||
|
"status": "error",
|
||||||
|
"message": result.get("error", "Unknown error"),
|
||||||
|
"source": result["source"]
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in profile picture API: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Unexpected error: {str(e)}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile-picture/metadata")
|
||||||
|
async def get_profile_picture_metadata():
|
||||||
|
"""Get metadata about the current profile picture"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
metadata = profile_picture_manager.load_metadata()
|
||||||
|
if metadata:
|
||||||
|
return {"status": "ok", "metadata": metadata}
|
||||||
|
else:
|
||||||
|
return {"status": "ok", "metadata": None, "message": "No metadata found"}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/restore-fallback")
|
||||||
|
async def restore_fallback_profile_picture():
|
||||||
|
"""Restore the original fallback profile picture"""
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
success = await profile_picture_manager.restore_fallback()
|
||||||
|
if success:
|
||||||
|
return {"status": "ok", "message": "Fallback profile picture restored"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to restore fallback"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/role-color/custom")
|
||||||
|
async def set_custom_role_color(hex_color: str = Form(...)):
|
||||||
|
"""Set a custom role color across all servers"""
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.set_custom_role_color(hex_color, debug=True)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Role color updated to {result['color']['hex']}",
|
||||||
|
"color": result["color"]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/role-color/reset-fallback")
|
||||||
|
async def reset_role_color_to_fallback():
|
||||||
|
"""Reset role color to fallback (#86cecb)"""
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.reset_to_fallback_color(debug=True)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Role color reset to fallback {result['color']['hex']}",
|
||||||
|
"color": result["color"]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to reset color"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Profile Picture — Image Serving ==========
|
||||||
|
|
||||||
|
@router.get("/profile-picture/image/original")
|
||||||
|
async def serve_original_profile_picture():
|
||||||
|
"""Serve the full-resolution original profile picture"""
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
path = profile_picture_manager.ORIGINAL_PATH
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "No original image found"})
|
||||||
|
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile-picture/image/current")
|
||||||
|
async def serve_current_profile_picture():
|
||||||
|
"""Serve the current cropped profile picture"""
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
path = profile_picture_manager.CURRENT_PATH
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "No current image found"})
|
||||||
|
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Profile Picture — Manual Crop Workflow ==========
|
||||||
|
|
||||||
|
@router.post("/profile-picture/change-no-crop")
|
||||||
|
async def trigger_profile_picture_change_no_crop(
|
||||||
|
guild_id: int = None,
|
||||||
|
file: UploadFile = File(None)
|
||||||
|
):
|
||||||
|
"""Change Miku's profile picture but skip auto-cropping."""
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
from server_manager import server_manager
|
||||||
|
|
||||||
|
mood = None
|
||||||
|
if guild_id is not None:
|
||||||
|
mood, _ = server_manager.get_server_mood(guild_id)
|
||||||
|
else:
|
||||||
|
mood = globals.DM_MOOD
|
||||||
|
|
||||||
|
custom_image_bytes = None
|
||||||
|
if file:
|
||||||
|
custom_image_bytes = await file.read()
|
||||||
|
logger.info(f"Received custom image for manual crop ({len(custom_image_bytes)} bytes)")
|
||||||
|
|
||||||
|
result = await profile_picture_manager.change_profile_picture(
|
||||||
|
mood=mood, custom_image_bytes=custom_image_bytes, debug=True, skip_crop=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Image saved for manual cropping",
|
||||||
|
"source": result["source"],
|
||||||
|
"metadata": result.get("metadata", {})
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={
|
||||||
|
"status": "error",
|
||||||
|
"message": result.get("error", "Unknown error"),
|
||||||
|
"source": result.get("source")
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in change-no-crop API: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Unexpected error: {str(e)}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/manual-crop")
|
||||||
|
async def apply_manual_crop(req: ManualCropRequest):
|
||||||
|
"""Apply a manual crop to the stored original image"""
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.manual_crop(
|
||||||
|
x=req.x, y=req.y, width=req.width, height=req.height, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Manual crop applied successfully",
|
||||||
|
"metadata": result.get("metadata", {})
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/auto-crop")
|
||||||
|
async def apply_auto_crop():
|
||||||
|
"""Run intelligent auto-crop on the stored original image"""
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.auto_crop_only(debug=True)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Auto-crop applied successfully",
|
||||||
|
"metadata": result.get("metadata", {})
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/description")
|
||||||
|
async def update_profile_picture_description(req: DescriptionUpdateRequest):
|
||||||
|
"""Update the profile picture description"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.update_description(
|
||||||
|
description=req.description, reinject_cat=True, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {"status": "ok", "message": "Description updated successfully"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/regenerate-description")
|
||||||
|
async def regenerate_profile_picture_description():
|
||||||
|
"""Re-generate the profile picture description using the vision model"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.regenerate_description(debug=True)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Description regenerated successfully",
|
||||||
|
"description": result["description"]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile-picture/description")
|
||||||
|
async def get_profile_picture_description():
|
||||||
|
"""Get the current profile picture description text"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
description = profile_picture_manager.get_current_description()
|
||||||
|
return {"status": "ok", "description": description or ""}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Profile Picture — Album / Gallery ==========
|
||||||
|
|
||||||
|
@router.get("/profile-picture/album")
|
||||||
|
async def list_album_entries():
|
||||||
|
"""List all album entries (newest first)"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
entries = profile_picture_manager.get_album_entries()
|
||||||
|
return {"status": "ok", "entries": entries, "count": len(entries)}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile-picture/album/disk-usage")
|
||||||
|
async def get_album_disk_usage():
|
||||||
|
"""Get album disk usage statistics"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
usage = profile_picture_manager.get_album_disk_usage()
|
||||||
|
return {"status": "ok", **usage}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile-picture/album/{entry_id}")
|
||||||
|
async def get_album_entry(entry_id: str):
|
||||||
|
"""Get metadata for a single album entry"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
meta = profile_picture_manager.get_album_entry(entry_id)
|
||||||
|
if meta:
|
||||||
|
return {"status": "ok", "entry": meta}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "Album entry not found"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile-picture/album/{entry_id}/image/{image_type}")
|
||||||
|
async def serve_album_image(entry_id: str, image_type: str):
|
||||||
|
"""Serve an album entry's image (original or cropped)"""
|
||||||
|
if image_type not in ("original", "cropped"):
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "image_type must be 'original' or 'cropped'"})
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
path = profile_picture_manager.get_album_image_path(entry_id, image_type)
|
||||||
|
if path:
|
||||||
|
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": f"No {image_type} image for this entry"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/album/add")
|
||||||
|
async def add_to_album(file: UploadFile = File(...)):
|
||||||
|
"""Add a single image to the album"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
image_bytes = await file.read()
|
||||||
|
logger.info(f"Adding image to album ({len(image_bytes)} bytes)")
|
||||||
|
result = await profile_picture_manager.add_to_album(
|
||||||
|
image_bytes=image_bytes, source="custom_upload", debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Image added to album",
|
||||||
|
"entry_id": result["entry_id"],
|
||||||
|
"metadata": result.get("metadata", {})
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding to album: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/album/add-batch")
|
||||||
|
async def add_batch_to_album(files: List[UploadFile] = File(...)):
|
||||||
|
"""Batch-add multiple images to the album efficiently"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
images = []
|
||||||
|
for f in files:
|
||||||
|
data = await f.read()
|
||||||
|
images.append({"bytes": data, "source": "custom_upload"})
|
||||||
|
logger.info(f"Batch adding {len(images)} images to album")
|
||||||
|
result = await profile_picture_manager.add_batch_to_album(images=images, debug=True)
|
||||||
|
return {
|
||||||
|
"status": "ok" if result["success"] else "partial",
|
||||||
|
"message": f"Added {result['succeeded']}/{result['total']} images",
|
||||||
|
"succeeded": result["succeeded"],
|
||||||
|
"failed": result["failed"],
|
||||||
|
"total": result["total"],
|
||||||
|
"results": [
|
||||||
|
{"entry_id": r.get("entry_id"), "success": r["success"], "error": r.get("error")}
|
||||||
|
for r in result["results"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in batch album add: {e}")
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/album/{entry_id}/set-current")
|
||||||
|
async def set_album_entry_as_current(entry_id: str):
|
||||||
|
"""Set an album entry as the current Discord profile picture"""
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"status": "error", "message": "Bot not ready"})
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.set_album_entry_as_current(
|
||||||
|
entry_id=entry_id, archive_current=True, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Album entry set as current profile picture",
|
||||||
|
"archived_entry_id": result.get("archived_entry_id")
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/album/{entry_id}/manual-crop")
|
||||||
|
async def manual_crop_album_entry(entry_id: str, req: AlbumCropRequest):
|
||||||
|
"""Manually crop an album entry's original image"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.manual_crop_album_entry(
|
||||||
|
entry_id=entry_id, x=req.x, y=req.y,
|
||||||
|
width=req.width, height=req.height, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Album entry cropped",
|
||||||
|
"metadata": result.get("metadata", {})
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/album/{entry_id}/auto-crop")
|
||||||
|
async def auto_crop_album_entry(entry_id: str):
|
||||||
|
"""Auto-crop an album entry using face/saliency detection"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.auto_crop_album_entry(
|
||||||
|
entry_id=entry_id, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Album entry auto-cropped",
|
||||||
|
"metadata": result.get("metadata", {})
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/album/{entry_id}/description")
|
||||||
|
async def update_album_entry_description(entry_id: str, req: AlbumDescriptionRequest):
|
||||||
|
"""Update an album entry's description"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.update_album_entry_description(
|
||||||
|
entry_id=entry_id, description=req.description, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {"status": "ok", "message": "Description updated"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": result.get("error", "Unknown error")})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/profile-picture/album/{entry_id}")
|
||||||
|
async def delete_album_entry(entry_id: str):
|
||||||
|
"""Delete a single album entry"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
if profile_picture_manager.delete_album_entry(entry_id):
|
||||||
|
return {"status": "ok", "message": "Album entry deleted"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "Album entry not found"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/album/delete-bulk")
|
||||||
|
async def bulk_delete_album_entries(req: BulkDeleteRequest):
|
||||||
|
"""Bulk delete multiple album entries"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = profile_picture_manager.delete_album_entries(req.entry_ids)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Deleted {result['deleted']}/{result['total']} entries",
|
||||||
|
**result
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile-picture/album/add-current")
|
||||||
|
async def add_current_to_album():
|
||||||
|
"""Archive the current profile picture into the album"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
entry_id = await profile_picture_manager._save_current_to_album(debug=True)
|
||||||
|
if entry_id:
|
||||||
|
return {"status": "ok", "message": "Current PFP archived to album", "entry_id": entry_id}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "No current PFP to archive"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
||||||
138
bot/routes/servers.py
Normal file
138
bot/routes/servers.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Server management routes: CRUD, bedtime, repair."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import globals
|
||||||
|
from server_manager import server_manager
|
||||||
|
from routes.models import ServerConfigRequest
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/servers")
|
||||||
|
def get_servers():
|
||||||
|
"""Get all configured servers"""
|
||||||
|
logger.debug("/servers endpoint called")
|
||||||
|
logger.debug(f"server_manager.servers keys: {list(server_manager.servers.keys())}")
|
||||||
|
logger.debug(f"server_manager.servers count: {len(server_manager.servers)}")
|
||||||
|
|
||||||
|
config_file = server_manager.config_file
|
||||||
|
logger.debug(f"Config file path: {config_file}")
|
||||||
|
if os.path.exists(config_file):
|
||||||
|
try:
|
||||||
|
with open(config_file, "r", encoding="utf-8") as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
logger.debug(f"Config file contains: {list(config_data.keys())}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read config file: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning("Config file does not exist")
|
||||||
|
|
||||||
|
servers = []
|
||||||
|
for server in server_manager.get_all_servers():
|
||||||
|
server_data = server.to_dict()
|
||||||
|
server_data['enabled_features'] = list(server_data['enabled_features'])
|
||||||
|
server_data['guild_id'] = str(server_data['guild_id'])
|
||||||
|
servers.append(server_data)
|
||||||
|
logger.debug(f"Adding server to response: {server_data['guild_id']} - {server_data['guild_name']}")
|
||||||
|
logger.debug(f"Server data type check - guild_id: {type(server_data['guild_id'])}, value: {server_data['guild_id']}")
|
||||||
|
|
||||||
|
logger.debug(f"Returning {len(servers)} servers")
|
||||||
|
return {"servers": servers}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers")
|
||||||
|
def add_server(data: ServerConfigRequest):
|
||||||
|
"""Add a new server configuration"""
|
||||||
|
enabled_features = set(data.enabled_features) if data.enabled_features else None
|
||||||
|
success = server_manager.add_server(
|
||||||
|
guild_id=data.guild_id,
|
||||||
|
guild_name=data.guild_name,
|
||||||
|
autonomous_channel_id=data.autonomous_channel_id,
|
||||||
|
autonomous_channel_name=data.autonomous_channel_name,
|
||||||
|
bedtime_channel_ids=data.bedtime_channel_ids,
|
||||||
|
enabled_features=enabled_features
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
server_manager.stop_all_schedulers()
|
||||||
|
server_manager.start_all_schedulers(globals.client)
|
||||||
|
return {"status": "ok", "message": f"Server {data.guild_name} added successfully"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to add server"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/servers/{guild_id}")
|
||||||
|
def remove_server(guild_id: int):
|
||||||
|
"""Remove a server configuration"""
|
||||||
|
success = server_manager.remove_server(guild_id)
|
||||||
|
if success:
|
||||||
|
return {"status": "ok", "message": "Server removed successfully"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=404, content={"status": "error", "message": "Failed to remove server"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/servers/{guild_id}")
|
||||||
|
def update_server(guild_id: int, data: dict):
|
||||||
|
"""Update server configuration"""
|
||||||
|
success = server_manager.update_server_config(guild_id, **data)
|
||||||
|
if success:
|
||||||
|
server_manager.stop_all_schedulers()
|
||||||
|
server_manager.start_all_schedulers(globals.client)
|
||||||
|
return {"status": "ok", "message": "Server configuration updated"}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to update server configuration"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/{guild_id}/bedtime-range")
|
||||||
|
def update_server_bedtime_range(guild_id: int, data: dict):
|
||||||
|
"""Update server bedtime range configuration"""
|
||||||
|
logger.debug(f"Updating bedtime range for server {guild_id}: {data}")
|
||||||
|
|
||||||
|
required_fields = ['bedtime_hour', 'bedtime_minute', 'bedtime_hour_end', 'bedtime_minute_end']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in data:
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": f"Missing required field: {field}"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
bedtime_hour = int(data['bedtime_hour'])
|
||||||
|
bedtime_minute = int(data['bedtime_minute'])
|
||||||
|
bedtime_hour_end = int(data['bedtime_hour_end'])
|
||||||
|
bedtime_minute_end = int(data['bedtime_minute_end'])
|
||||||
|
|
||||||
|
if not (0 <= bedtime_hour <= 23) or not (0 <= bedtime_hour_end <= 23):
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Hours must be between 0 and 23"})
|
||||||
|
if not (0 <= bedtime_minute <= 59) or not (0 <= bedtime_minute_end <= 59):
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Minutes must be between 0 and 59"})
|
||||||
|
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return JSONResponse(status_code=400, content={"status": "error", "message": "Invalid time values provided"})
|
||||||
|
|
||||||
|
success = server_manager.update_server_config(guild_id, **data)
|
||||||
|
if success:
|
||||||
|
job_success = server_manager.update_server_bedtime_job(guild_id, globals.client)
|
||||||
|
if job_success:
|
||||||
|
logger.info(f"Bedtime range updated for server {guild_id}")
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Bedtime range updated: {bedtime_hour:02d}:{bedtime_minute:02d} - {bedtime_hour_end:02d}:{bedtime_minute_end:02d}"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Updated config but failed to update scheduler"})
|
||||||
|
else:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to update bedtime range"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/servers/repair")
|
||||||
|
def repair_server_config():
|
||||||
|
"""Repair corrupted server configuration"""
|
||||||
|
try:
|
||||||
|
server_manager.repair_config()
|
||||||
|
return {"status": "ok", "message": "Server configuration repaired and saved"}
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(status_code=500, content={"status": "error", "message": f"Failed to repair configuration: {e}"})
|
||||||
208
bot/routes/voice.py
Normal file
208
bot/routes/voice.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""Voice call management routes + helpers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from fastapi import APIRouter, Form
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import discord
|
||||||
|
import globals
|
||||||
|
from utils.dm_logger import dm_logger
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('api')
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/voice/call")
|
||||||
|
async def initiate_voice_call(user_id: str = Form(...), voice_channel_id: str = Form(...)):
|
||||||
|
"""
|
||||||
|
Initiate a voice call to a user.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Start STT and TTS containers
|
||||||
|
2. Wait for models to load (health check)
|
||||||
|
3. Join voice channel
|
||||||
|
4. Send DM with invite to user
|
||||||
|
5. Wait for user to join (30min timeout)
|
||||||
|
6. Auto-disconnect 45s after user leaves
|
||||||
|
"""
|
||||||
|
logger.info(f"📞 Voice call initiated for user {user_id} in channel {voice_channel_id}")
|
||||||
|
|
||||||
|
# Check if bot is running
|
||||||
|
if not globals.client or not globals.client.loop or not globals.client.loop.is_running():
|
||||||
|
return JSONResponse(status_code=503, content={"success": False, "error": "Bot is not running"})
|
||||||
|
|
||||||
|
# Run the voice call setup in the bot's event loop
|
||||||
|
try:
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
_initiate_voice_call_impl(user_id, voice_channel_id),
|
||||||
|
globals.client.loop
|
||||||
|
)
|
||||||
|
result = future.result(timeout=90) # 90 second timeout for container warmup
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error initiating voice call: {e}", exc_info=True)
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
async def _initiate_voice_call_impl(user_id: str, voice_channel_id: str):
|
||||||
|
"""Implementation of voice call initiation that runs in the bot's event loop."""
|
||||||
|
from utils.container_manager import ContainerManager
|
||||||
|
from utils.voice_manager import VoiceSessionManager
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert string IDs to integers for Discord API
|
||||||
|
user_id_int = int(user_id)
|
||||||
|
channel_id_int = int(voice_channel_id)
|
||||||
|
|
||||||
|
# Get user and channel
|
||||||
|
user = await globals.client.fetch_user(user_id_int)
|
||||||
|
if not user:
|
||||||
|
return JSONResponse(status_code=404, content={"success": False, "error": "User not found"})
|
||||||
|
|
||||||
|
channel = globals.client.get_channel(channel_id_int)
|
||||||
|
if not channel or not isinstance(channel, discord.VoiceChannel):
|
||||||
|
return JSONResponse(status_code=404, content={"success": False, "error": "Voice channel not found"})
|
||||||
|
|
||||||
|
# Get a text channel for voice operations (use first text channel in guild)
|
||||||
|
text_channel = None
|
||||||
|
for ch in channel.guild.text_channels:
|
||||||
|
if ch.permissions_for(channel.guild.me).send_messages:
|
||||||
|
text_channel = ch
|
||||||
|
break
|
||||||
|
|
||||||
|
if not text_channel:
|
||||||
|
return JSONResponse(status_code=404, content={"success": False, "error": "No accessible text channel found"})
|
||||||
|
|
||||||
|
# Start containers
|
||||||
|
logger.info("Starting voice containers...")
|
||||||
|
containers_started = await ContainerManager.start_voice_containers()
|
||||||
|
|
||||||
|
if not containers_started:
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": "Failed to start voice containers"})
|
||||||
|
|
||||||
|
# Start voice session
|
||||||
|
logger.info(f"Starting voice session in {channel.name}")
|
||||||
|
session_manager = VoiceSessionManager()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session_manager.start_session(channel.guild.id, channel, text_channel)
|
||||||
|
except Exception as e:
|
||||||
|
await ContainerManager.stop_voice_containers()
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": f"Failed to start voice session: {str(e)}"})
|
||||||
|
|
||||||
|
# Set up voice call tracking (use integer ID)
|
||||||
|
session_manager.active_session.call_user_id = user_id_int
|
||||||
|
|
||||||
|
# Generate invite link
|
||||||
|
invite = await channel.create_invite(
|
||||||
|
max_age=1800, # 30 minutes
|
||||||
|
max_uses=1,
|
||||||
|
reason="Miku voice call"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send DM to user
|
||||||
|
try:
|
||||||
|
# Get LLM to generate a personalized invitation message
|
||||||
|
from utils.llm import query_llama
|
||||||
|
|
||||||
|
invitation_prompt = f"""You're calling {user.name} in voice chat! Generate a cute, excited message inviting them to join you.
|
||||||
|
Keep it brief (1-2 sentences). Make it feel personal and enthusiastic!"""
|
||||||
|
|
||||||
|
invitation_text = await query_llama(
|
||||||
|
user_prompt=invitation_prompt,
|
||||||
|
user_id=user.id,
|
||||||
|
guild_id=None,
|
||||||
|
response_type="voice_call_invite",
|
||||||
|
author_name=user.name
|
||||||
|
)
|
||||||
|
|
||||||
|
dm_message = f"📞 **Miku is calling you! Very experimental! Speak clearly, loudly and close to the mic! Expect weirdness!** 📞\n\n{invitation_text}\n\n🎤 Join here: {invite.url}"
|
||||||
|
|
||||||
|
sent_message = await user.send(dm_message)
|
||||||
|
|
||||||
|
# Log to DM logger
|
||||||
|
dm_logger.log_user_message(user, sent_message, is_bot_message=True)
|
||||||
|
|
||||||
|
logger.info(f"✓ DM sent to {user.name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send DM: {e}")
|
||||||
|
# Don't fail the whole call if DM fails
|
||||||
|
|
||||||
|
# Set up 30min timeout task
|
||||||
|
session_manager.active_session.call_timeout_task = asyncio.create_task(
|
||||||
|
_voice_call_timeout_handler(session_manager.active_session, user, channel)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user_id": user_id,
|
||||||
|
"channel_id": voice_channel_id,
|
||||||
|
"invite_url": invite.url
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in voice call implementation: {e}", exc_info=True)
|
||||||
|
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
async def _voice_call_timeout_handler(voice_session, user: discord.User, channel: discord.VoiceChannel):
|
||||||
|
"""Handle 30min timeout if user doesn't join."""
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(1800) # 30 minutes
|
||||||
|
|
||||||
|
# Check if user ever joined
|
||||||
|
if not voice_session.user_has_joined:
|
||||||
|
logger.info(f"Voice call timeout - user {user.name} never joined")
|
||||||
|
|
||||||
|
# End the session (which triggers cleanup)
|
||||||
|
from utils.voice_manager import VoiceSessionManager
|
||||||
|
session_manager = VoiceSessionManager()
|
||||||
|
await session_manager.end_session()
|
||||||
|
|
||||||
|
# Stop containers
|
||||||
|
from utils.container_manager import ContainerManager
|
||||||
|
await ContainerManager.stop_voice_containers()
|
||||||
|
|
||||||
|
# Send timeout DM
|
||||||
|
try:
|
||||||
|
timeout_message = "Aww, I guess you couldn't make it to voice chat... Maybe next time! 💙"
|
||||||
|
sent_message = await user.send(timeout_message)
|
||||||
|
|
||||||
|
# Log to DM logger
|
||||||
|
dm_logger.log_user_message(user, sent_message, is_bot_message=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# User joined in time, normal operation
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/voice/debug-mode")
|
||||||
|
def get_voice_debug_mode():
|
||||||
|
"""Get current voice debug mode status"""
|
||||||
|
return {
|
||||||
|
"debug_mode": globals.VOICE_DEBUG_MODE
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/voice/debug-mode")
|
||||||
|
def set_voice_debug_mode(enabled: bool = Form(...)):
|
||||||
|
"""Set voice debug mode (shows transcriptions and responses in text channel)"""
|
||||||
|
globals.VOICE_DEBUG_MODE = enabled
|
||||||
|
logger.info(f"Voice debug mode set to: {enabled}")
|
||||||
|
|
||||||
|
# Persist so it survives restarts
|
||||||
|
try:
|
||||||
|
from config_manager import config_manager
|
||||||
|
config_manager.set("voice.debug_mode", enabled, persist=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"debug_mode": enabled,
|
||||||
|
"message": f"Voice debug mode {'enabled' if enabled else 'disabled'}"
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ from apscheduler.triggers.interval import IntervalTrigger
|
|||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from apscheduler.triggers.date import DateTrigger
|
from apscheduler.triggers.date import DateTrigger
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from utils.logger import get_logger
|
from utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger('server')
|
logger = get_logger('server')
|
||||||
@@ -40,7 +39,7 @@ class ServerConfig:
|
|||||||
previous_mood_name: str = "neutral"
|
previous_mood_name: str = "neutral"
|
||||||
is_sleeping: bool = False
|
is_sleeping: bool = False
|
||||||
sleepy_responses_left: Optional[int] = None
|
sleepy_responses_left: Optional[int] = None
|
||||||
angry_wakeup_timer: Optional[float] = None # Unused, kept for structural completeness
|
angry_wakeup_timer: Optional[float] = None # TODO: implement angry-wakeup mechanic or remove field
|
||||||
forced_angry_until: Optional[str] = None # ISO format datetime string, or None
|
forced_angry_until: Optional[str] = None # ISO format datetime string, or None
|
||||||
just_woken_up: bool = False
|
just_woken_up: bool = False
|
||||||
|
|
||||||
@@ -76,7 +75,6 @@ class ServerManager:
|
|||||||
self.config_file = config_file
|
self.config_file = config_file
|
||||||
self.servers: Dict[int, ServerConfig] = {}
|
self.servers: Dict[int, ServerConfig] = {}
|
||||||
self.schedulers: Dict[int, AsyncIOScheduler] = {}
|
self.schedulers: Dict[int, AsyncIOScheduler] = {}
|
||||||
self.server_memories: Dict[int, Dict] = {}
|
|
||||||
self._wakeup_tasks: Dict[int, asyncio.Task] = {} # guild_id -> delayed wakeup task
|
self._wakeup_tasks: Dict[int, asyncio.Task] = {} # guild_id -> delayed wakeup task
|
||||||
self.load_config()
|
self.load_config()
|
||||||
|
|
||||||
@@ -89,16 +87,15 @@ class ServerManager:
|
|||||||
for guild_id_str, server_data in data.items():
|
for guild_id_str, server_data in data.items():
|
||||||
guild_id = int(guild_id_str)
|
guild_id = int(guild_id_str)
|
||||||
self.servers[guild_id] = ServerConfig.from_dict(server_data)
|
self.servers[guild_id] = ServerConfig.from_dict(server_data)
|
||||||
self.server_memories[guild_id] = {}
|
|
||||||
logger.info(f"Loaded config for server: {server_data['guild_name']} (ID: {guild_id})")
|
logger.info(f"Loaded config for server: {server_data['guild_name']} (ID: {guild_id})")
|
||||||
|
|
||||||
# After loading, check if we need to repair the config
|
# After loading, check if we need to repair the config
|
||||||
self.repair_config()
|
self.repair_config()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load server config: {e}")
|
logger.error(f"Failed to load server config: {e}")
|
||||||
self._create_default_config()
|
logger.info("Starting with zero servers — add servers via the API or dashboard")
|
||||||
else:
|
else:
|
||||||
self._create_default_config()
|
logger.info("No servers_config.json found — starting with zero servers")
|
||||||
|
|
||||||
def repair_config(self):
|
def repair_config(self):
|
||||||
"""Repair corrupted configuration data and save it back"""
|
"""Repair corrupted configuration data and save it back"""
|
||||||
@@ -124,23 +121,6 @@ class ServerManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to repair config: {e}")
|
logger.error(f"Failed to repair config: {e}")
|
||||||
|
|
||||||
def _create_default_config(self):
|
|
||||||
"""Create default configuration for backward compatibility"""
|
|
||||||
default_server = ServerConfig(
|
|
||||||
guild_id=759889672804630530,
|
|
||||||
guild_name="Default Server",
|
|
||||||
autonomous_channel_id=761014220707332107,
|
|
||||||
autonomous_channel_name="miku-chat",
|
|
||||||
bedtime_channel_ids=[761014220707332107],
|
|
||||||
enabled_features={"autonomous", "bedtime", "monday_video"},
|
|
||||||
autonomous_interval_minutes=10,
|
|
||||||
conversation_detection_interval_minutes=3
|
|
||||||
)
|
|
||||||
self.servers[default_server.guild_id] = default_server
|
|
||||||
self.server_memories[default_server.guild_id] = {}
|
|
||||||
self.save_config()
|
|
||||||
logger.info("Created default server configuration")
|
|
||||||
|
|
||||||
def save_config(self):
|
def save_config(self):
|
||||||
"""Save server configurations to file"""
|
"""Save server configurations to file"""
|
||||||
try:
|
try:
|
||||||
@@ -183,7 +163,6 @@ class ServerManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.servers[guild_id] = server
|
self.servers[guild_id] = server
|
||||||
self.server_memories[guild_id] = {}
|
|
||||||
self.save_config()
|
self.save_config()
|
||||||
logger.info(f"Added new server: {guild_name} (ID: {guild_id})")
|
logger.info(f"Added new server: {guild_name} (ID: {guild_id})")
|
||||||
return True
|
return True
|
||||||
@@ -201,10 +180,6 @@ class ServerManager:
|
|||||||
self.schedulers[guild_id].shutdown()
|
self.schedulers[guild_id].shutdown()
|
||||||
del self.schedulers[guild_id]
|
del self.schedulers[guild_id]
|
||||||
|
|
||||||
# Remove memory
|
|
||||||
if guild_id in self.server_memories:
|
|
||||||
del self.server_memories[guild_id]
|
|
||||||
|
|
||||||
self.save_config()
|
self.save_config()
|
||||||
logger.info(f"Removed server: {server_name} (ID: {guild_id})")
|
logger.info(f"Removed server: {server_name} (ID: {guild_id})")
|
||||||
return True
|
return True
|
||||||
@@ -231,23 +206,6 @@ class ServerManager:
|
|||||||
logger.info(f"Updated config for server: {server.guild_name}")
|
logger.info(f"Updated config for server: {server.guild_name}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_server_memory(self, guild_id: int, key: str = None):
|
|
||||||
"""Get or set server-specific memory"""
|
|
||||||
if guild_id not in self.server_memories:
|
|
||||||
self.server_memories[guild_id] = {}
|
|
||||||
|
|
||||||
if key is None:
|
|
||||||
return self.server_memories[guild_id]
|
|
||||||
|
|
||||||
return self.server_memories[guild_id].get(key)
|
|
||||||
|
|
||||||
def set_server_memory(self, guild_id: int, key: str, value):
|
|
||||||
"""Set server-specific memory"""
|
|
||||||
if guild_id not in self.server_memories:
|
|
||||||
self.server_memories[guild_id] = {}
|
|
||||||
|
|
||||||
self.server_memories[guild_id][key] = value
|
|
||||||
|
|
||||||
# ========== Mood Management Methods ==========
|
# ========== Mood Management Methods ==========
|
||||||
def get_server_mood(self, guild_id: int) -> tuple[str, str]:
|
def get_server_mood(self, guild_id: int) -> tuple[str, str]:
|
||||||
"""Get current mood name and description for a server"""
|
"""Get current mood name and description for a server"""
|
||||||
|
|||||||
917
bot/static/css/style.css
Normal file
917
bot/static/css/style.css
Normal file
@@ -0,0 +1,917 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: #121212;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: 60%;
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
width: 40%;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #000;
|
||||||
|
color: #0f0;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: scroll;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-left: 2px solid #333;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logs-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line { line-height: 1.4; }
|
||||||
|
.log-line.log-error { color: #ff6b6b; }
|
||||||
|
.log-line.log-warning { color: #ffd93d; }
|
||||||
|
.log-line.log-info { color: #0f0; }
|
||||||
|
.log-line.log-debug { color: #888; }
|
||||||
|
|
||||||
|
.logs-paused-indicator {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(50, 50, 0, 0.9);
|
||||||
|
color: #ffd93d;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
select, button, input {
|
||||||
|
margin: 0.4rem 0.5rem 0.4rem 0;
|
||||||
|
padding: 0.4rem;
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: #1e1e1e;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h3 {
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
#notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: #222;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 8px;
|
||||||
|
opacity: 0.95;
|
||||||
|
display: none;
|
||||||
|
z-index: 3000;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-name {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: #444;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin: 0.2rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-server-form {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #333;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-users-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-user-card {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-user-card:hover {
|
||||||
|
border-color: #666;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-user-card h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-user-card p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-user-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blocked Users Styles */
|
||||||
|
.blocked-users-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-user-card {
|
||||||
|
background: #3d2a2a;
|
||||||
|
border: 1px solid #664444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-user-card:hover {
|
||||||
|
border-color: #886666;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-user-card h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-user-card p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocked-user-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Conversation View Styles */
|
||||||
|
.message-reactions {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-item:hover {
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-emoji {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-by {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-by.bot-reaction {
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-by.user-reaction {
|
||||||
|
color: #ffa726;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-message-btn {
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-message-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-user-actions button {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-view {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversations-list {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-message {
|
||||||
|
background: #333;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-message.user-message {
|
||||||
|
border-left: 4px solid #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-message.bot-message {
|
||||||
|
border-left: 4px solid #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-attachments {
|
||||||
|
background: #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment a {
|
||||||
|
color: #4CAF50;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab styling */
|
||||||
|
.tab-container {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(2, auto);
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: max-content;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #555 #222;
|
||||||
|
row-gap: 0.05rem;
|
||||||
|
column-gap: 0.1rem;
|
||||||
|
padding-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons::-webkit-scrollbar-track {
|
||||||
|
background: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-buttons::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
background: #222;
|
||||||
|
color: #ccc;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:hover {
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
background: #444;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prompt source toggle buttons */
|
||||||
|
.prompt-source-btn {
|
||||||
|
background: #333;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.prompt-source-btn.active {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.prompt-source-btn:hover:not(.active) {
|
||||||
|
background: #444;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prompt History Section */
|
||||||
|
#prompt-history-section.collapsed #prompt-history-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#prompt-history-toggle {
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
#prompt-history-toggle:hover {
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
#prompt-metadata span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#prompt-metadata .prompt-meta-label {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
#prompt-metadata .prompt-meta-value {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
#prompt-display pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.prompt-subsection-header {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #2a2a2a;
|
||||||
|
margin: 0.5rem 0 0.25rem 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #aaa;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.prompt-subsection-header:hover {
|
||||||
|
background: #333;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
.prompt-subsection-body.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#prompt-truncate-toggle {
|
||||||
|
accent-color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mood Activities Editor */
|
||||||
|
.act-mood-row {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.act-mood-header {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #2a2a2a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.act-mood-header:hover { background: #333; }
|
||||||
|
.act-mood-header .act-mood-name { font-weight: bold; min-width: 120px; }
|
||||||
|
.act-mood-header .act-mood-stats { color: #888; font-size: 0.8rem; }
|
||||||
|
.act-mood-content { display: none; padding: 0.75rem; background: #1e1e1e; }
|
||||||
|
.act-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
.act-entry:last-child { border-bottom: none; }
|
||||||
|
.act-entry-icon { font-size: 1.1rem; min-width: 24px; text-align: center; }
|
||||||
|
.act-entry input[type="text"] { flex: 1; }
|
||||||
|
.act-entry input[type="number"] { width: 55px; }
|
||||||
|
.act-entry select { width: 130px; }
|
||||||
|
.act-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab loading spinner */
|
||||||
|
.tab-loading-overlay {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 1rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.tab-loading-overlay .spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid #444;
|
||||||
|
border-top-color: #4CAF50;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat Interface Styles */
|
||||||
|
.chat-message {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
animation: fadeIn 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user-message {
|
||||||
|
background: #2a3a4a;
|
||||||
|
border-left: 4px solid #4CAF50;
|
||||||
|
margin-left: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.assistant-message {
|
||||||
|
background: #3a2a3a;
|
||||||
|
border-left: 4px solid #61dafb;
|
||||||
|
margin-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.error-message {
|
||||||
|
background: #4a2a2a;
|
||||||
|
border-left: 4px solid #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-sender {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user-message .chat-message-sender {
|
||||||
|
color: #4CAF50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-time {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-content {
|
||||||
|
color: #ddd;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-indicator span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #61dafb;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: typing 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-indicator span:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-typing-indicator span:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typing {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); opacity: 0.7; }
|
||||||
|
30% { transform: translateY(-10px); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-messages::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-messages::-webkit-scrollbar-track {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-messages::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-messages::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Evil Mode Styles */
|
||||||
|
body.evil-mode h1, body.evil-mode h3 {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.evil-mode .tab-button.active {
|
||||||
|
border-bottom-color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.evil-mode #evil-mode-toggle {
|
||||||
|
background: #ff4444;
|
||||||
|
border-color: #ff4444;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.evil-mode .server-name {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.evil-mode .chat-message-sender {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.evil-mode .chat-message.assistant-message {
|
||||||
|
border-left-color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.evil-mode #notification {
|
||||||
|
border-color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override any blue status text in evil mode */
|
||||||
|
body.evil-mode [style*="color: #007bff"],
|
||||||
|
body.evil-mode [style*="color: rgb(0, 123, 255)"] {
|
||||||
|
color: #ff4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bipolar Mode Styles */
|
||||||
|
#bipolar-section {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bipolar-section h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bipolar-mode-toggle.bipolar-active {
|
||||||
|
background: #9932CC !important;
|
||||||
|
border-color: #9932CC !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive breakpoints */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.panel { width: 55%; padding: 1.5rem; }
|
||||||
|
.logs { width: 45%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
body { flex-direction: column; }
|
||||||
|
.panel { width: 100%; padding: 1.5rem; }
|
||||||
|
.logs {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 2px solid #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.panel { padding: 1rem; }
|
||||||
|
.tab-buttons {
|
||||||
|
grid-template-rows: none;
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
}
|
||||||
|
.tab-button { font-size: 0.85rem; padding: 0.4rem 0.6rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.panel { padding: 0.5rem; }
|
||||||
|
.tab-buttons { grid-template-columns: 1fr 1fr; }
|
||||||
|
.tab-button { font-size: 0.8rem; padding: 0.35rem 0.5rem; }
|
||||||
|
h1 { font-size: 1.2rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile Picture Tab Styles */
|
||||||
|
.pfp-preview-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pfp-preview-box {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pfp-preview-box img {
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 400px;
|
||||||
|
border: 2px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
.pfp-preview-box .label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.pfp-crop-container {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 550px;
|
||||||
|
background: #111;
|
||||||
|
border: 2px solid #555;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.pfp-crop-container img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.crop-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.crop-mode-toggle label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.crop-mode-toggle input[type="radio"] {
|
||||||
|
accent-color: #4CAF50;
|
||||||
|
}
|
||||||
|
.pfp-description-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #ddd;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.pfp-description-editor:focus {
|
||||||
|
border-color: #61dafb;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
/* Album / Gallery grid */
|
||||||
|
.album-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.album-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.album-header h4 { margin: 0; }
|
||||||
|
.album-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.album-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
.album-card {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
.album-card:hover { border-color: #61dafb; }
|
||||||
|
.album-card.selected { border-color: #4CAF50; box-shadow: 0 0 8px rgba(76,175,80,0.4); }
|
||||||
|
.album-card.checked { border-color: #ff9800; }
|
||||||
|
.album-card img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.album-card .album-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
accent-color: #ff9800;
|
||||||
|
}
|
||||||
|
.album-card .album-card-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #ccc;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.album-card .color-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #888;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
.album-detail {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #222;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.album-detail-previews {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.album-detail-previews .pfp-preview-box img {
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
.album-disk-usage {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
432
bot/static/js/actions.js
Normal file
432
bot/static/js/actions.js
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Miku Control Panel — Actions Module
|
||||||
|
// Autonomous actions, manual actions, custom prompts, reactions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ===== Autonomous Actions =====
|
||||||
|
|
||||||
|
async function triggerAutonomous(actionType) {
|
||||||
|
const selectedServer = document.getElementById('server-select').value;
|
||||||
|
|
||||||
|
if (!actionType) {
|
||||||
|
showNotification('No action type specified', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let endpoint = `/autonomous/${actionType}`;
|
||||||
|
|
||||||
|
if (selectedServer !== 'all') {
|
||||||
|
endpoint += `?guild_id=${selectedServer}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCall(endpoint, 'POST');
|
||||||
|
showNotification(result.message || 'Action triggered successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to trigger autonomous action:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEngageSubmenu() {
|
||||||
|
const submenu = document.getElementById('engage-submenu');
|
||||||
|
submenu.style.display = submenu.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerEngageUser() {
|
||||||
|
const selectedServer = document.getElementById('server-select').value;
|
||||||
|
const userId = document.getElementById('engage-user-id').value.trim();
|
||||||
|
const engageType = document.querySelector('input[name="engage-type"]:checked').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let endpoint = '/autonomous/engage';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (selectedServer !== 'all') {
|
||||||
|
params.append('guild_id', selectedServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
params.append('user_id', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (engageType !== 'random') {
|
||||||
|
params.append('engagement_type', engageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.append('manual_trigger', 'true');
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
endpoint += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCall(endpoint, 'POST');
|
||||||
|
showNotification(result.message || 'Engagement triggered successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to trigger user engagement:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTweetSubmenu() {
|
||||||
|
const submenu = document.getElementById('tweet-submenu');
|
||||||
|
submenu.style.display = submenu.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerShareTweet() {
|
||||||
|
const selectedServer = document.getElementById('server-select').value;
|
||||||
|
const tweetUrl = document.getElementById('tweet-url').value.trim();
|
||||||
|
|
||||||
|
if (tweetUrl) {
|
||||||
|
const validDomains = ['x.com', 'twitter.com', 'fxtwitter.com'];
|
||||||
|
let isValid = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(tweetUrl);
|
||||||
|
const hostname = urlObj.hostname.toLowerCase();
|
||||||
|
isValid = validDomains.some(domain => hostname === domain || hostname.endsWith('.' + domain));
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
showNotification('Invalid tweet URL. Must be from x.com, twitter.com, or fxtwitter.com', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let endpoint = '/autonomous/tweet';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (selectedServer !== 'all') {
|
||||||
|
params.append('guild_id', selectedServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tweetUrl) {
|
||||||
|
params.append('tweet_url', tweetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
endpoint += `?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCall(endpoint, 'POST');
|
||||||
|
showNotification(result.message || 'Tweet share triggered successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to trigger tweet share:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Manual Actions =====
|
||||||
|
|
||||||
|
async function forceSleep() {
|
||||||
|
try {
|
||||||
|
await apiCall('/sleep', 'POST');
|
||||||
|
showNotification('Miku is now sleeping');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to force sleep:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wakeUp() {
|
||||||
|
try {
|
||||||
|
await apiCall('/wake', 'POST');
|
||||||
|
showNotification('Miku is now awake');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to wake up:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendBedtime() {
|
||||||
|
const selectedServer = document.getElementById('manual-server-select').value;
|
||||||
|
|
||||||
|
console.log('🛏️ sendBedtime() called');
|
||||||
|
console.log('🛏️ Selected server value:', selectedServer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let endpoint = '/bedtime';
|
||||||
|
|
||||||
|
if (selectedServer !== 'all') {
|
||||||
|
console.log('🛏️ Using guild_id (as string):', selectedServer);
|
||||||
|
endpoint += `?guild_id=${selectedServer}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🛏️ Final endpoint:', endpoint);
|
||||||
|
|
||||||
|
const result = await apiCall(endpoint, 'POST');
|
||||||
|
showNotification(result.message || 'Bedtime reminder sent successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send bedtime reminder:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetConversation() {
|
||||||
|
const userId = prompt('Enter user ID to reset conversation for:');
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
await apiCall('/conversation/reset', 'POST', { user_id: userId });
|
||||||
|
showNotification('Conversation reset');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset conversation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Manual Message =====
|
||||||
|
|
||||||
|
async function sendManualMessage() {
|
||||||
|
const message = document.getElementById('manualMessage').value.trim();
|
||||||
|
const files = document.getElementById('manualAttachment').files;
|
||||||
|
const targetType = document.getElementById('manual-target-type').value;
|
||||||
|
const replyMessageId = document.getElementById('manualReplyMessageId').value.trim();
|
||||||
|
const replyMention = document.querySelector('input[name="manualReplyMention"]:checked').value === 'true';
|
||||||
|
const useWebhook = document.getElementById('manual-use-webhook').checked;
|
||||||
|
const webhookPersona = document.querySelector('input[name="webhook-persona"]:checked')?.value || 'miku';
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
showNotification('Please enter a message', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useWebhook && targetType === 'dm') {
|
||||||
|
showNotification('Webhooks only work in channels, not DMs', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetId, endpoint;
|
||||||
|
|
||||||
|
if (targetType === 'dm') {
|
||||||
|
targetId = document.getElementById('manualUserId').value.trim();
|
||||||
|
if (!targetId) {
|
||||||
|
showNotification('Please enter a user ID for DM', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endpoint = `/dm/${targetId}/manual`;
|
||||||
|
} else {
|
||||||
|
targetId = document.getElementById('manualChannelId').value.trim();
|
||||||
|
if (!targetId) {
|
||||||
|
showNotification('Please enter a channel ID', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endpoint = useWebhook ? '/manual/send-webhook' : '/manual/send';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('message', message);
|
||||||
|
|
||||||
|
if (useWebhook) {
|
||||||
|
formData.append('persona', webhookPersona);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replyMessageId) {
|
||||||
|
formData.append('reply_to_message_id', replyMessageId);
|
||||||
|
formData.append('mention_author', replyMention);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === 'dm') {
|
||||||
|
if (files.length > 0) {
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append('files', files[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
formData.append('channel_id', targetId);
|
||||||
|
if (files.length > 0) {
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append('files', files[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showNotification('Message sent successfully');
|
||||||
|
document.getElementById('manualMessage').value = '';
|
||||||
|
document.getElementById('manualAttachment').value = '';
|
||||||
|
document.getElementById('manualReplyMessageId').value = '';
|
||||||
|
if (targetType === 'dm') {
|
||||||
|
document.getElementById('manualUserId').value = '';
|
||||||
|
} else {
|
||||||
|
document.getElementById('manualChannelId').value = '';
|
||||||
|
}
|
||||||
|
document.getElementById('manualStatus').textContent = '✅ Message sent successfully!';
|
||||||
|
document.getElementById('manualStatus').style.color = 'green';
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Failed to send message');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send manual message:', error);
|
||||||
|
showNotification(error.message || 'Failed to send message', 'error');
|
||||||
|
document.getElementById('manualStatus').textContent = '❌ Failed to send message';
|
||||||
|
document.getElementById('manualStatus').style.color = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Custom Prompt =====
|
||||||
|
|
||||||
|
function toggleCustomPromptTarget() {
|
||||||
|
const targetType = document.getElementById('custom-prompt-target-type').value;
|
||||||
|
const serverSection = document.getElementById('custom-prompt-server-section');
|
||||||
|
const dmSection = document.getElementById('custom-prompt-dm-section');
|
||||||
|
|
||||||
|
if (targetType === 'dm') {
|
||||||
|
serverSection.style.display = 'none';
|
||||||
|
dmSection.style.display = 'inline';
|
||||||
|
} else {
|
||||||
|
serverSection.style.display = 'inline';
|
||||||
|
dmSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleWebhookOptions() {
|
||||||
|
const useWebhook = document.getElementById('manual-use-webhook').checked;
|
||||||
|
const webhookOptions = document.getElementById('webhook-persona-options');
|
||||||
|
const targetType = document.getElementById('manual-target-type');
|
||||||
|
|
||||||
|
if (useWebhook) {
|
||||||
|
webhookOptions.style.display = 'block';
|
||||||
|
if (targetType.value === 'dm') {
|
||||||
|
targetType.value = 'channel';
|
||||||
|
toggleManualMessageTarget();
|
||||||
|
}
|
||||||
|
targetType.options[1].disabled = true;
|
||||||
|
} else {
|
||||||
|
webhookOptions.style.display = 'none';
|
||||||
|
targetType.options[1].disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleManualMessageTarget() {
|
||||||
|
const targetType = document.getElementById('manual-target-type').value;
|
||||||
|
const channelSection = document.getElementById('manual-channel-section');
|
||||||
|
const dmSection = document.getElementById('manual-dm-section');
|
||||||
|
|
||||||
|
if (targetType === 'dm') {
|
||||||
|
channelSection.style.display = 'none';
|
||||||
|
dmSection.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
channelSection.style.display = 'block';
|
||||||
|
dmSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCustomPrompt() {
|
||||||
|
const prompt = document.getElementById('customPrompt').value.trim();
|
||||||
|
const targetType = document.getElementById('custom-prompt-target-type').value;
|
||||||
|
const files = document.getElementById('customPromptAttachment').files;
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
showNotification('Please enter a custom prompt', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let endpoint;
|
||||||
|
|
||||||
|
if (targetType === 'dm') {
|
||||||
|
const userId = document.getElementById('custom-prompt-user-id').value.trim();
|
||||||
|
if (!userId) {
|
||||||
|
showNotification('Please enter a user ID for DM', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endpoint = `/dm/${userId}/custom`;
|
||||||
|
} else {
|
||||||
|
const selectedServer = document.getElementById('custom-prompt-server-select').value;
|
||||||
|
endpoint = '/autonomous/custom';
|
||||||
|
|
||||||
|
if (selectedServer !== 'all') {
|
||||||
|
endpoint += `?guild_id=${selectedServer}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCall(endpoint, 'POST', { prompt: prompt });
|
||||||
|
|
||||||
|
showNotification(result.message || 'Custom prompt sent successfully');
|
||||||
|
document.getElementById('customPrompt').value = '';
|
||||||
|
document.getElementById('customPromptAttachment').value = '';
|
||||||
|
if (targetType === 'dm') {
|
||||||
|
document.getElementById('custom-prompt-user-id').value = '';
|
||||||
|
}
|
||||||
|
document.getElementById('customStatus').textContent = '✅ Custom prompt sent successfully!';
|
||||||
|
document.getElementById('customStatus').style.color = 'green';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send custom prompt:', error);
|
||||||
|
document.getElementById('customStatus').textContent = '❌ Failed to send custom prompt';
|
||||||
|
document.getElementById('customStatus').style.color = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustomPrompt() {
|
||||||
|
const customPromptSection = document.getElementById('custom-prompt-section');
|
||||||
|
if (customPromptSection) {
|
||||||
|
customPromptSection.style.display = customPromptSection.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Add Reaction =====
|
||||||
|
|
||||||
|
async function addReactionToMessage() {
|
||||||
|
const messageId = document.getElementById('reactionMessageId').value.trim();
|
||||||
|
const channelId = document.getElementById('reactionChannelId').value.trim();
|
||||||
|
const emoji = document.getElementById('reactionEmoji').value.trim();
|
||||||
|
const statusElement = document.getElementById('reactionStatus');
|
||||||
|
|
||||||
|
if (!messageId) {
|
||||||
|
showNotification('Please enter a message ID', 'error');
|
||||||
|
statusElement.textContent = '❌ Message ID is required';
|
||||||
|
statusElement.style.color = 'red';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelId) {
|
||||||
|
showNotification('Please enter a channel ID', 'error');
|
||||||
|
statusElement.textContent = '❌ Channel ID is required';
|
||||||
|
statusElement.style.color = 'red';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emoji) {
|
||||||
|
showNotification('Please enter an emoji', 'error');
|
||||||
|
statusElement.textContent = '❌ Emoji is required';
|
||||||
|
statusElement.style.color = 'red';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
statusElement.textContent = '⏳ Adding reaction...';
|
||||||
|
statusElement.style.color = '#61dafb';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('message_id', messageId);
|
||||||
|
formData.append('channel_id', channelId);
|
||||||
|
formData.append('emoji', emoji);
|
||||||
|
|
||||||
|
const response = await fetch('/messages/react', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.status === 'ok') {
|
||||||
|
showNotification(`Reaction ${emoji} added successfully`);
|
||||||
|
statusElement.textContent = `✅ Reaction ${emoji} added successfully!`;
|
||||||
|
statusElement.style.color = 'green';
|
||||||
|
|
||||||
|
document.getElementById('reactionMessageId').value = '';
|
||||||
|
document.getElementById('reactionChannelId').value = '';
|
||||||
|
document.getElementById('reactionEmoji').value = '';
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Failed to add reaction');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add reaction:', error);
|
||||||
|
showNotification(error.message || 'Failed to add reaction', 'error');
|
||||||
|
statusElement.textContent = `❌ ${error.message || 'Failed to add reaction'}`;
|
||||||
|
statusElement.style.color = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
498
bot/static/js/chat.js
Normal file
498
bot/static/js/chat.js
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Miku Control Panel — Chat Interface + Voice Call Module
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Toggle image upload section based on model type
|
||||||
|
function toggleChatImageUpload() {
|
||||||
|
const modelType = document.querySelector('input[name="chat-model-type"]:checked').value;
|
||||||
|
const imageUploadSection = document.getElementById('chat-image-upload-section');
|
||||||
|
|
||||||
|
if (modelType === 'vision') {
|
||||||
|
imageUploadSection.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
imageUploadSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load voice debug mode setting from server
|
||||||
|
async function loadVoiceDebugMode() {
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/voice/debug-mode');
|
||||||
|
const checkbox = document.getElementById('voice-debug-mode');
|
||||||
|
if (checkbox && data.debug_mode !== undefined) {
|
||||||
|
checkbox.checked = data.debug_mode;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load voice debug mode:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Enter key in chat input
|
||||||
|
function handleChatKeyPress(event) {
|
||||||
|
if (event.ctrlKey && event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
sendChatMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear chat history
|
||||||
|
function clearChatHistory() {
|
||||||
|
if (confirm('Are you sure you want to clear all chat messages?')) {
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
chatMessages.innerHTML = `
|
||||||
|
<div style="text-align: center; color: #888; padding: 2rem;">
|
||||||
|
💬 Start chatting with the LLM! Your conversation will appear here.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
// Clear conversation history array
|
||||||
|
chatConversationHistory = [];
|
||||||
|
showNotification('Chat history cleared');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a message to the chat display
|
||||||
|
function addChatMessage(sender, content, isError = false) {
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
|
||||||
|
// Remove welcome message if it exists
|
||||||
|
const welcomeMsg = chatMessages.querySelector('div[style*="text-align: center"]');
|
||||||
|
if (welcomeMsg) {
|
||||||
|
welcomeMsg.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
const messageClass = isError ? 'error-message' : (sender === 'You' ? 'user-message' : 'assistant-message');
|
||||||
|
messageDiv.className = `chat-message ${messageClass}`;
|
||||||
|
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
|
||||||
|
messageDiv.innerHTML = `
|
||||||
|
<div class="chat-message-header">
|
||||||
|
<span class="chat-message-sender">${escapeHtml(sender)}</span>
|
||||||
|
<span class="chat-message-time">${timestamp}</span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-message-content"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Set content via textContent to prevent XSS
|
||||||
|
messageDiv.querySelector('.chat-message-content').textContent = content;
|
||||||
|
|
||||||
|
chatMessages.appendChild(messageDiv);
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
|
||||||
|
return messageDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add typing indicator
|
||||||
|
function showTypingIndicator() {
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
|
||||||
|
const typingDiv = document.createElement('div');
|
||||||
|
typingDiv.id = 'chat-typing-indicator';
|
||||||
|
typingDiv.className = 'chat-message assistant-message';
|
||||||
|
typingDiv.innerHTML = `
|
||||||
|
<div class="chat-message-header">
|
||||||
|
<span class="chat-message-sender">Miku</span>
|
||||||
|
<span class="chat-message-time">typing...</span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-typing-indicator">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
chatMessages.appendChild(typingDiv);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove typing indicator
|
||||||
|
function hideTypingIndicator() {
|
||||||
|
const typingIndicator = document.getElementById('chat-typing-indicator');
|
||||||
|
if (typingIndicator) {
|
||||||
|
typingIndicator.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send chat message with streaming support
|
||||||
|
async function sendChatMessage() {
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
const message = input.value.trim();
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
showNotification('Please enter a message', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get configuration
|
||||||
|
const modelType = document.querySelector('input[name="chat-model-type"]:checked').value;
|
||||||
|
const useSystemPrompt = document.querySelector('input[name="chat-system-prompt"]:checked').value === 'true';
|
||||||
|
const selectedMood = document.getElementById('chat-mood-select').value;
|
||||||
|
|
||||||
|
// Get image data if vision model
|
||||||
|
let imageData = null;
|
||||||
|
if (modelType === 'vision') {
|
||||||
|
const imageFile = document.getElementById('chat-image-file').files[0];
|
||||||
|
if (imageFile) {
|
||||||
|
try {
|
||||||
|
imageData = await readFileAsBase64(imageFile);
|
||||||
|
// Remove data URL prefix if present
|
||||||
|
if (imageData.includes(',')) {
|
||||||
|
imageData = imageData.split(',')[1];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Failed to read image file', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable send button
|
||||||
|
const sendBtn = document.getElementById('chat-send-btn');
|
||||||
|
const originalBtnText = sendBtn.innerHTML;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.innerHTML = '⏳ Sending...';
|
||||||
|
|
||||||
|
// Add user message to display
|
||||||
|
addChatMessage('You', message);
|
||||||
|
|
||||||
|
// Clear input
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
// Show typing indicator
|
||||||
|
showTypingIndicator();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build user message for history
|
||||||
|
let userMessageContent;
|
||||||
|
if (modelType === 'vision' && imageData) {
|
||||||
|
// Vision model with image - store as multimodal content
|
||||||
|
userMessageContent = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": message
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": `data:image/jpeg;base64,${imageData}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Text-only message
|
||||||
|
userMessageContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare request payload with conversation history
|
||||||
|
const payload = {
|
||||||
|
message: message,
|
||||||
|
model_type: modelType,
|
||||||
|
use_system_prompt: useSystemPrompt,
|
||||||
|
image_data: imageData,
|
||||||
|
conversation_history: chatConversationHistory,
|
||||||
|
mood: selectedMood
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make streaming request
|
||||||
|
const response = await fetch('/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide typing indicator
|
||||||
|
hideTypingIndicator();
|
||||||
|
|
||||||
|
// Create message element for streaming response
|
||||||
|
const assistantName = useSystemPrompt ? 'Miku' : 'LLM';
|
||||||
|
const responseDiv = addChatMessage(assistantName, '');
|
||||||
|
const contentDiv = responseDiv.querySelector('.chat-message-content');
|
||||||
|
|
||||||
|
// Read stream
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let fullResponse = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Process complete SSE messages
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const dataStr = line.slice(6);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(dataStr);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
contentDiv.textContent = `❌ Error: ${data.error}`;
|
||||||
|
responseDiv.classList.add('error-message');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.content) {
|
||||||
|
fullResponse += data.content;
|
||||||
|
contentDiv.textContent = fullResponse;
|
||||||
|
|
||||||
|
// Auto-scroll
|
||||||
|
const chatMessages = document.getElementById('chat-messages');
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse SSE data:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no response was received, show error
|
||||||
|
if (!fullResponse) {
|
||||||
|
contentDiv.textContent = '❌ No response received from LLM';
|
||||||
|
responseDiv.classList.add('error-message');
|
||||||
|
} else {
|
||||||
|
// Add user message to conversation history
|
||||||
|
chatConversationHistory.push({
|
||||||
|
role: "user",
|
||||||
|
content: userMessageContent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add assistant response to conversation history
|
||||||
|
chatConversationHistory.push({
|
||||||
|
role: "assistant",
|
||||||
|
content: fullResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('💬 Conversation history updated:', chatConversationHistory.length, 'messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Chat error:', error);
|
||||||
|
hideTypingIndicator();
|
||||||
|
addChatMessage('Error', `Failed to send message: ${error.message}`, true);
|
||||||
|
showNotification('Failed to send message', 'error');
|
||||||
|
} finally {
|
||||||
|
// Re-enable send button
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.innerHTML = originalBtnText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to read file as base64
|
||||||
|
function readFileAsBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Voice Call Management Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function initiateVoiceCall() {
|
||||||
|
const userId = document.getElementById('voice-user-id').value.trim();
|
||||||
|
const channelId = document.getElementById('voice-channel-id').value.trim();
|
||||||
|
const debugMode = document.getElementById('voice-debug-mode').checked;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!userId) {
|
||||||
|
showNotification('Please enter a user ID', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelId) {
|
||||||
|
showNotification('Please enter a voice channel ID', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user IDs are valid (numeric)
|
||||||
|
if (isNaN(userId) || isNaN(channelId)) {
|
||||||
|
showNotification('User ID and Channel ID must be numeric', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set debug mode
|
||||||
|
try {
|
||||||
|
const debugFormData = new FormData();
|
||||||
|
debugFormData.append('enabled', debugMode);
|
||||||
|
await fetch('/voice/debug-mode', {
|
||||||
|
method: 'POST',
|
||||||
|
body: debugFormData
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set debug mode:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable button and show status
|
||||||
|
const callBtn = document.getElementById('voice-call-btn');
|
||||||
|
const cancelBtn = document.getElementById('voice-call-cancel-btn');
|
||||||
|
const statusDiv = document.getElementById('voice-call-status');
|
||||||
|
const statusText = document.getElementById('voice-call-status-text');
|
||||||
|
|
||||||
|
callBtn.disabled = true;
|
||||||
|
statusDiv.style.display = 'block';
|
||||||
|
cancelBtn.style.display = 'inline-block';
|
||||||
|
voiceCallActive = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
statusText.innerHTML = '⏳ Starting STT and TTS containers...';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('user_id', userId);
|
||||||
|
formData.append('voice_channel_id', channelId);
|
||||||
|
|
||||||
|
const response = await fetch('/voice/call', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Check for HTTP error status (422 validation error, etc.)
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMsg = data.error || data.detail || 'Unknown error';
|
||||||
|
// Handle FastAPI validation errors
|
||||||
|
if (data.detail && Array.isArray(data.detail)) {
|
||||||
|
errorMsg = data.detail.map(e => `${e.loc.join('.')}: ${e.msg}`).join(', ');
|
||||||
|
}
|
||||||
|
statusText.innerHTML = `❌ Error: ${errorMsg}`;
|
||||||
|
showNotification(`Voice call failed: ${errorMsg}`, 'error');
|
||||||
|
callBtn.disabled = false;
|
||||||
|
cancelBtn.style.display = 'none';
|
||||||
|
voiceCallActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
statusText.innerHTML = `❌ Error: ${data.error}`;
|
||||||
|
showNotification(`Voice call failed: ${data.error}`, 'error');
|
||||||
|
callBtn.disabled = false;
|
||||||
|
cancelBtn.style.display = 'none';
|
||||||
|
voiceCallActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success!
|
||||||
|
statusText.innerHTML = `✅ Voice call initiated!<br>User ID: ${data.user_id}<br>Channel: ${data.channel_id}`;
|
||||||
|
|
||||||
|
// Show invite link
|
||||||
|
const inviteDiv = document.getElementById('voice-call-invite-link');
|
||||||
|
const inviteUrl = document.getElementById('voice-call-invite-url');
|
||||||
|
inviteUrl.href = data.invite_url;
|
||||||
|
inviteUrl.textContent = data.invite_url;
|
||||||
|
inviteDiv.style.display = 'block';
|
||||||
|
|
||||||
|
// Add to call history
|
||||||
|
addVoiceCallToHistory(userId, channelId, data.invite_url);
|
||||||
|
|
||||||
|
showNotification('Voice call initiated successfully!', 'success');
|
||||||
|
|
||||||
|
// Auto-reset after 5 minutes (call should be done by then or timed out)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (voiceCallActive) {
|
||||||
|
resetVoiceCall();
|
||||||
|
}
|
||||||
|
}, 300000); // 5 minutes
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Voice call error:', error);
|
||||||
|
statusText.innerHTML = `❌ Error: ${error.message}`;
|
||||||
|
showNotification(`Voice call error: ${error.message}`, 'error');
|
||||||
|
callBtn.disabled = false;
|
||||||
|
cancelBtn.style.display = 'none';
|
||||||
|
voiceCallActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelVoiceCall() {
|
||||||
|
resetVoiceCall();
|
||||||
|
showNotification('Voice call cancelled', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetVoiceCall() {
|
||||||
|
const callBtn = document.getElementById('voice-call-btn');
|
||||||
|
const cancelBtn = document.getElementById('voice-call-cancel-btn');
|
||||||
|
const statusDiv = document.getElementById('voice-call-status');
|
||||||
|
|
||||||
|
callBtn.disabled = false;
|
||||||
|
cancelBtn.style.display = 'none';
|
||||||
|
statusDiv.style.display = 'none';
|
||||||
|
voiceCallActive = false;
|
||||||
|
|
||||||
|
// Clear inputs
|
||||||
|
document.getElementById('voice-user-id').value = '';
|
||||||
|
document.getElementById('voice-channel-id').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVoiceCallToHistory(userId, channelId, inviteUrl) {
|
||||||
|
const now = new Date();
|
||||||
|
const timestamp = now.toLocaleTimeString();
|
||||||
|
|
||||||
|
const callEntry = {
|
||||||
|
userId: userId,
|
||||||
|
channelId: channelId,
|
||||||
|
inviteUrl: inviteUrl,
|
||||||
|
timestamp: timestamp
|
||||||
|
};
|
||||||
|
|
||||||
|
voiceCallHistory.unshift(callEntry); // Add to front
|
||||||
|
|
||||||
|
// Keep only last 10 calls
|
||||||
|
if (voiceCallHistory.length > 10) {
|
||||||
|
voiceCallHistory.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVoiceCallHistoryDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVoiceCallHistoryDisplay() {
|
||||||
|
const historyDiv = document.getElementById('voice-call-history');
|
||||||
|
|
||||||
|
if (voiceCallHistory.length === 0) {
|
||||||
|
historyDiv.innerHTML = '<div style="text-align: center; color: #888;">No calls yet. Start one above!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
voiceCallHistory.forEach((call, index) => {
|
||||||
|
html += `
|
||||||
|
<div style="background: #242424; padding: 0.75rem; margin-bottom: 0.5rem; border-radius: 4px; border-left: 3px solid #61dafb;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<strong>${call.timestamp}</strong>
|
||||||
|
<div style="font-size: 0.85rem; color: #aaa; margin-top: 0.3rem;">
|
||||||
|
User: <code>${call.userId}</code> | Channel: <code>${call.channelId}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="${call.inviteUrl}" target="_blank" style="color: #61dafb; text-decoration: none; padding: 0.3rem 0.7rem; background: #333; border-radius: 4px; font-size: 0.85rem;">
|
||||||
|
View Link →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
historyDiv.innerHTML = html;
|
||||||
|
}
|
||||||
419
bot/static/js/core.js
Normal file
419
bot/static/js/core.js
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Miku Control Panel — Core Module
|
||||||
|
// Global variables, utility functions, tab switching, initialization, polling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Global variables
|
||||||
|
let currentMood = 'neutral';
|
||||||
|
let voiceCallActive = false;
|
||||||
|
let voiceCallHistory = [];
|
||||||
|
let servers = [];
|
||||||
|
let evilMode = false;
|
||||||
|
let bipolarMode = false;
|
||||||
|
let selectedGPU = 'nvidia';
|
||||||
|
let chatConversationHistory = [];
|
||||||
|
let pfpCropper = null;
|
||||||
|
let albumEntries = [];
|
||||||
|
let albumSelectedId = null;
|
||||||
|
let albumChecked = new Set();
|
||||||
|
let albumCropper = null;
|
||||||
|
let albumOpen = false;
|
||||||
|
let activitiesData = null;
|
||||||
|
let activitiesOpen = false;
|
||||||
|
let activitiesSections = { normal: false, evil: false };
|
||||||
|
let activitiesEditing = {};
|
||||||
|
let activitiesEditCache = {};
|
||||||
|
let currentEditMemory = null;
|
||||||
|
let logsAutoScroll = true;
|
||||||
|
let notificationTimer = null;
|
||||||
|
let statusInterval = null;
|
||||||
|
let logsInterval = null;
|
||||||
|
let argsInterval = null;
|
||||||
|
let promptInterval = null;
|
||||||
|
|
||||||
|
// Mood emoji mapping
|
||||||
|
const MOOD_EMOJIS = {
|
||||||
|
"asleep": "💤",
|
||||||
|
"neutral": "",
|
||||||
|
"bubbly": "🫧",
|
||||||
|
"sleepy": "🌙",
|
||||||
|
"curious": "👀",
|
||||||
|
"shy": "👉👈",
|
||||||
|
"serious": "👔",
|
||||||
|
"excited": "✨",
|
||||||
|
"melancholy": "🍷",
|
||||||
|
"flirty": "🫦",
|
||||||
|
"romantic": "💌",
|
||||||
|
"irritated": "😒",
|
||||||
|
"angry": "💢",
|
||||||
|
"silly": "🪿"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Evil mood emoji mapping
|
||||||
|
const EVIL_MOOD_EMOJIS = {
|
||||||
|
"aggressive": "👿",
|
||||||
|
"cunning": "🐍",
|
||||||
|
"sarcastic": "😈",
|
||||||
|
"evil_neutral": "",
|
||||||
|
"bored": "🥱",
|
||||||
|
"manic": "🤪",
|
||||||
|
"jealous": "💚",
|
||||||
|
"melancholic": "🌑",
|
||||||
|
"playful_cruel": "🎭",
|
||||||
|
"contemptuous": "👑"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.style.display = 'block';
|
||||||
|
notification.style.opacity = '0.95';
|
||||||
|
|
||||||
|
if (type === 'error') {
|
||||||
|
notification.style.backgroundColor = '#d32f2f';
|
||||||
|
} else if (type === 'success') {
|
||||||
|
notification.style.backgroundColor = '#2e7d32';
|
||||||
|
} else {
|
||||||
|
notification.style.backgroundColor = '#222';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notificationTimer) clearTimeout(notificationTimer);
|
||||||
|
notificationTimer = setTimeout(() => {
|
||||||
|
notification.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.display = 'none';
|
||||||
|
notificationTimer = null;
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiCall(endpoint, method = 'GET', data = null) {
|
||||||
|
try {
|
||||||
|
const options = {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
options.body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, options);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'API call failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API call error:', error);
|
||||||
|
showNotification(error.message, 'error');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeJsonForAttribute(obj) {
|
||||||
|
return JSON.stringify(obj)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Tab switching
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function switchTab(tabId) {
|
||||||
|
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||||
|
tab.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-button').forEach(button => {
|
||||||
|
button.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById(tabId).classList.add('active');
|
||||||
|
|
||||||
|
const activeBtn = document.querySelector(`.tab-button[data-tab="${tabId}"]`);
|
||||||
|
if (activeBtn) activeBtn.classList.add('active');
|
||||||
|
|
||||||
|
localStorage.setItem('miku-active-tab', tabId);
|
||||||
|
|
||||||
|
console.log(`🔄 Switched to ${tabId}`);
|
||||||
|
if (tabId === 'tab1') {
|
||||||
|
console.log('🔄 Refreshing figurine subscribers for Server Management tab');
|
||||||
|
refreshFigurineSubscribers();
|
||||||
|
}
|
||||||
|
if (tabId === 'tab3') {
|
||||||
|
loadStatus();
|
||||||
|
loadLastPrompt();
|
||||||
|
}
|
||||||
|
if (tabId === 'tab6') {
|
||||||
|
showTabLoading('tab6');
|
||||||
|
loadAutonomousStats().finally(() => hideTabLoading('tab6'));
|
||||||
|
}
|
||||||
|
if (tabId === 'tab9') {
|
||||||
|
console.log('🧠 Refreshing memory stats for Memories tab');
|
||||||
|
showTabLoading('tab9');
|
||||||
|
refreshMemoryStats().finally(() => hideTabLoading('tab9'));
|
||||||
|
}
|
||||||
|
if (tabId === 'tab10') {
|
||||||
|
console.log('📱 Loading DM users for DM Management tab');
|
||||||
|
showTabLoading('tab10');
|
||||||
|
loadDMUsers().finally(() => hideTabLoading('tab10'));
|
||||||
|
}
|
||||||
|
if (tabId === 'tab11') {
|
||||||
|
console.log('🖼️ Loading Profile Picture tab');
|
||||||
|
loadPfpTab();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTabLoading(tabId) {
|
||||||
|
const tab = document.getElementById(tabId);
|
||||||
|
if (!tab) return;
|
||||||
|
if (tab.querySelector('.tab-loading-overlay')) return;
|
||||||
|
const sections = tab.querySelectorAll('.section');
|
||||||
|
const hasContent = Array.from(sections).some(s => s.querySelector('[id]')?.innerHTML?.trim());
|
||||||
|
if (hasContent) return;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'tab-loading-overlay';
|
||||||
|
overlay.innerHTML = '<div class="spinner"></div> Loading...';
|
||||||
|
tab.prepend(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTabLoading(tabId) {
|
||||||
|
const tab = document.getElementById(tabId);
|
||||||
|
if (!tab) return;
|
||||||
|
const overlay = tab.querySelector('.tab-loading-overlay');
|
||||||
|
if (overlay) overlay.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Polling
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (!statusInterval) statusInterval = setInterval(loadStatus, 10000);
|
||||||
|
if (!logsInterval) logsInterval = setInterval(loadLogs, 5000);
|
||||||
|
if (!argsInterval) argsInterval = setInterval(loadActiveArguments, 5000);
|
||||||
|
if (!promptInterval) promptInterval = setInterval(loadPromptHistory, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
clearInterval(statusInterval); statusInterval = null;
|
||||||
|
clearInterval(logsInterval); logsInterval = null;
|
||||||
|
clearInterval(argsInterval); argsInterval = null;
|
||||||
|
clearInterval(promptInterval); promptInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Initialization helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function initTabState() {
|
||||||
|
const savedTab = localStorage.getItem('miku-active-tab');
|
||||||
|
if (savedTab && document.getElementById(savedTab)) {
|
||||||
|
switchTab(savedTab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTabWheelScroll() {
|
||||||
|
const tabButtonsEl = document.querySelector('.tab-buttons');
|
||||||
|
if (tabButtonsEl) {
|
||||||
|
tabButtonsEl.addEventListener('wheel', function(e) {
|
||||||
|
if (e.deltaY !== 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
tabButtonsEl.scrollLeft += e.deltaY;
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVisibilityPolling() {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stopPolling();
|
||||||
|
console.log('⏸ Tab hidden — polling paused');
|
||||||
|
} else {
|
||||||
|
loadStatus(); loadLogs(); loadActiveArguments(); loadPromptHistory();
|
||||||
|
startPolling();
|
||||||
|
console.log('▶️ Tab visible — polling resumed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChatImagePreview() {
|
||||||
|
const imageInput = document.getElementById('chat-image-file');
|
||||||
|
if (imageInput) {
|
||||||
|
imageInput.addEventListener('change', function(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(event) {
|
||||||
|
const preview = document.getElementById('chat-image-preview');
|
||||||
|
const previewImg = document.getElementById('chat-image-preview-img');
|
||||||
|
previewImg.src = event.target.result;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initModalAccessibility() {
|
||||||
|
const editModal = document.getElementById('edit-memory-modal');
|
||||||
|
const createModal = document.getElementById('create-memory-modal');
|
||||||
|
if (editModal) {
|
||||||
|
editModal.setAttribute('role', 'dialog');
|
||||||
|
editModal.setAttribute('aria-modal', 'true');
|
||||||
|
editModal.setAttribute('aria-label', 'Edit Memory');
|
||||||
|
editModal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeEditMemoryModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (createModal) {
|
||||||
|
createModal.setAttribute('role', 'dialog');
|
||||||
|
createModal.setAttribute('aria-modal', 'true');
|
||||||
|
createModal.setAttribute('aria-label', 'Create Memory');
|
||||||
|
createModal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeCreateMemoryModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPromptSourceToggle() {
|
||||||
|
const saved = localStorage.getItem('miku-prompt-source') || 'all';
|
||||||
|
document.querySelectorAll('.prompt-source-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
const btnId = saved === 'all' ? 'prompt-src-all' : `prompt-src-${saved}`;
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
if (btn) btn.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLogsScrollDetection() {
|
||||||
|
const logsPanel = document.getElementById('logs-panel');
|
||||||
|
if (!logsPanel) return;
|
||||||
|
logsPanel.addEventListener('scroll', function() {
|
||||||
|
const atBottom = logsPanel.scrollHeight - logsPanel.scrollTop - logsPanel.clientHeight < 50;
|
||||||
|
logsAutoScroll = atBottom;
|
||||||
|
const banner = document.getElementById('logs-paused-banner');
|
||||||
|
if (banner) banner.style.display = atBottom ? 'none' : 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollLogsToBottom() {
|
||||||
|
const logsPanel = document.getElementById('logs-panel');
|
||||||
|
if (logsPanel) {
|
||||||
|
logsPanel.scrollTop = logsPanel.scrollHeight;
|
||||||
|
logsAutoScroll = true;
|
||||||
|
const banner = document.getElementById('logs-paused-banner');
|
||||||
|
if (banner) banner.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Log functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function classifyLogLine(line) {
|
||||||
|
const upper = line.toUpperCase();
|
||||||
|
if (upper.includes(' ERROR ') || upper.includes(' CRITICAL ') || upper.startsWith('ERROR') || upper.startsWith('CRITICAL') || upper.includes('TRACEBACK')) return 'log-error';
|
||||||
|
if (upper.includes(' WARNING ') || upper.startsWith('WARNING')) return 'log-warning';
|
||||||
|
if (upper.includes(' DEBUG ') || upper.startsWith('DEBUG')) return 'log-debug';
|
||||||
|
return 'log-info';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/logs');
|
||||||
|
const logsContent = document.getElementById('logs-content');
|
||||||
|
const lines = (result || '').split('\n');
|
||||||
|
logsContent.innerHTML = lines.map(line => {
|
||||||
|
if (!line.trim()) return '';
|
||||||
|
const cls = classifyLogLine(line);
|
||||||
|
return `<div class="log-line ${cls}">${escapeHtml(line)}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (logsAutoScroll) {
|
||||||
|
scrollLogsToBottom();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load logs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Prompt source toggle (shared between core and status modules)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function switchPromptSource(source) {
|
||||||
|
localStorage.setItem('miku-prompt-source', source);
|
||||||
|
document.querySelectorAll('.prompt-source-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
const btnId = source === 'all' ? 'prompt-src-all' : `prompt-src-${source}`;
|
||||||
|
const btn = document.getElementById(btnId);
|
||||||
|
if (btn) btn.classList.add('active');
|
||||||
|
loadPromptHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Profile picture metadata (stub — actual loading in profile.js)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function loadProfilePictureMetadata() {
|
||||||
|
// Delegated to PFP tab loader — only runs if tab11 is active
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DOMContentLoaded — main initialization
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initTabState();
|
||||||
|
initTabWheelScroll();
|
||||||
|
initLogsScrollDetection();
|
||||||
|
initChatImagePreview();
|
||||||
|
initModalAccessibility();
|
||||||
|
initPromptSourceToggle();
|
||||||
|
|
||||||
|
loadStatus();
|
||||||
|
loadServers();
|
||||||
|
populateMoodDropdowns();
|
||||||
|
loadLastPrompt();
|
||||||
|
loadLogs();
|
||||||
|
checkEvilModeStatus();
|
||||||
|
checkBipolarModeStatus();
|
||||||
|
checkGPUStatus();
|
||||||
|
refreshLanguageStatus();
|
||||||
|
refreshFigurineSubscribers();
|
||||||
|
loadProfilePictureMetadata();
|
||||||
|
loadVoiceDebugMode();
|
||||||
|
|
||||||
|
initVisibilityPolling();
|
||||||
|
startPolling();
|
||||||
|
|
||||||
|
// Modal keyboard close handler
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const editModal = document.getElementById('edit-memory-modal');
|
||||||
|
const createModal = document.getElementById('create-memory-modal');
|
||||||
|
if (editModal && editModal.style.display !== 'none') closeEditMemoryModal();
|
||||||
|
if (createModal && createModal.style.display !== 'none') closeCreateMemoryModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
548
bot/static/js/dm.js
Normal file
548
bot/static/js/dm.js
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Miku Control Panel — DM Management Module
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function loadDMUsers() {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/dms/users');
|
||||||
|
displayDMUsers(result.users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load DM users:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayDMUsers(users) {
|
||||||
|
const container = document.getElementById('dm-users-list');
|
||||||
|
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
container.innerHTML = '<p>No DM conversations found.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="dm-users-grid">';
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
console.log(`👤 Processing user: ${user.username} (ID: ${user.user_id})`);
|
||||||
|
|
||||||
|
const lastMessage = user.last_message ?
|
||||||
|
`Last: ${user.last_message.content}` :
|
||||||
|
'No messages yet';
|
||||||
|
|
||||||
|
const lastTime = user.last_message ?
|
||||||
|
new Date(user.last_message.timestamp).toLocaleString() :
|
||||||
|
'Never';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="dm-user-card">
|
||||||
|
<h4>👤 ${user.username}</h4>
|
||||||
|
<p><strong>ID:</strong> ${user.user_id}</p>
|
||||||
|
<p><strong>Total Messages:</strong> ${user.total_messages}</p>
|
||||||
|
<p><strong>User Messages:</strong> ${user.user_messages}</p>
|
||||||
|
<p><strong>Bot Messages:</strong> ${user.bot_messages}</p>
|
||||||
|
<p><strong>Last Activity:</strong> ${lastTime}</p>
|
||||||
|
<p><strong>Last Message:</strong> ${lastMessage}</p>
|
||||||
|
<div class="dm-user-actions">
|
||||||
|
<button class="view-chat-btn" data-user-id="${user.user_id}">💬 View Chat</button>
|
||||||
|
<button class="analyze-user-btn" data-user-id="${user.user_id}" data-username="${user.username}" style="background: #9c27b0;">📊 Analyze</button>
|
||||||
|
<button class="export-dms-btn" data-user-id="${user.user_id}">📤 Export</button>
|
||||||
|
<button class="block-user-btn" data-user-id="${user.user_id}" data-username="${user.username}" style="background: #ff9800;">🚫 Block</button>
|
||||||
|
<button class="delete-all-dms-btn" data-user-id="${user.user_id}" data-username="${user.username}" style="background: #f44336;">🗑️ Delete All</button>
|
||||||
|
<button class="delete-user-completely-btn" data-user-id="${user.user_id}" data-username="${user.username}" style="background: #d32f2f;">💀 Delete User</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Add event listeners after HTML is inserted
|
||||||
|
addDMUserEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDMUserEventListeners() {
|
||||||
|
// Add event listeners for view chat buttons
|
||||||
|
document.querySelectorAll('.view-chat-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const userId = this.getAttribute('data-user-id');
|
||||||
|
console.log(`🎯 View chat clicked for user ID: ${userId} (type: ${typeof userId})`);
|
||||||
|
viewUserConversations(userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners for export buttons
|
||||||
|
document.querySelectorAll('.export-dms-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const userId = this.getAttribute('data-user-id');
|
||||||
|
console.log(`🎯 Export clicked for user ID: ${userId} (type: ${typeof userId})`);
|
||||||
|
exportUserDMs(userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners for analyze buttons
|
||||||
|
document.querySelectorAll('.analyze-user-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const userId = this.getAttribute('data-user-id');
|
||||||
|
const username = this.getAttribute('data-username');
|
||||||
|
console.log(`🎯 Analyze clicked for user ID: ${userId} (type: ${typeof userId})`);
|
||||||
|
analyzeUserInteraction(userId, username);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners for block buttons
|
||||||
|
document.querySelectorAll('.block-user-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const userId = this.getAttribute('data-user-id');
|
||||||
|
const username = this.getAttribute('data-username');
|
||||||
|
console.log(`🎯 Block clicked for user ID: ${userId} (type: ${typeof userId})`);
|
||||||
|
blockUser(userId, username);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners for delete all DMs buttons
|
||||||
|
document.querySelectorAll('.delete-all-dms-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const userId = this.getAttribute('data-user-id');
|
||||||
|
const username = this.getAttribute('data-username');
|
||||||
|
console.log(`🎯 Delete all DMs clicked for user ID: ${userId} (type: ${typeof userId})`);
|
||||||
|
deleteAllUserConversations(userId, username);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners for delete user completely buttons
|
||||||
|
document.querySelectorAll('.delete-user-completely-btn').forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
const userId = this.getAttribute('data-user-id');
|
||||||
|
const username = this.getAttribute('data-username');
|
||||||
|
console.log(`🎯 Delete user completely clicked for user ID: ${userId} (type: ${typeof userId})`);
|
||||||
|
deleteUserCompletely(userId, username);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewUserConversations(userId) {
|
||||||
|
try {
|
||||||
|
// Ensure userId is always treated as a string
|
||||||
|
const userIdStr = String(userId);
|
||||||
|
console.log(`🔍 Loading conversations for user ${userIdStr} (type: ${typeof userIdStr})`);
|
||||||
|
console.log(`🔍 Original userId: ${userId} (type: ${typeof userId})`);
|
||||||
|
console.log(`🔍 userIdStr: ${userIdStr} (type: ${typeof userIdStr})`);
|
||||||
|
|
||||||
|
const result = await apiCall(`/dms/users/${userIdStr}/conversations?limit=100`);
|
||||||
|
|
||||||
|
console.log('📡 API Response:', result);
|
||||||
|
console.log('📡 API URL called:', `/dms/users/${userIdStr}/conversations?limit=100`);
|
||||||
|
|
||||||
|
if (result.conversations && result.conversations.length > 0) {
|
||||||
|
console.log(`✅ Found ${result.conversations.length} conversations`);
|
||||||
|
displayUserConversations(userIdStr, result.conversations);
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ No conversations found in response');
|
||||||
|
showNotification('No conversations found for this user', 'info');
|
||||||
|
// Go back to user list
|
||||||
|
loadDMUsers();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user conversations:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayUserConversations(userId, conversations) {
|
||||||
|
console.log(`🎨 Displaying conversations for user ${userId}:`, conversations);
|
||||||
|
|
||||||
|
// Create a modal or expand the user card to show conversations
|
||||||
|
const container = document.getElementById('dm-users-list');
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="conversation-view">
|
||||||
|
<button onclick="loadDMUsers()" style="margin-bottom: 1rem;">← Back to DM Users</button>
|
||||||
|
<h4>💬 Conversations with User ${userId}</h4>
|
||||||
|
<div class="conversations-list">
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!conversations || conversations.length === 0) {
|
||||||
|
html += '<p>No conversations found for this user.</p>';
|
||||||
|
} else {
|
||||||
|
conversations.forEach((msg, index) => {
|
||||||
|
console.log(`📝 Processing message ${index}:`, msg);
|
||||||
|
const timestamp = new Date(msg.timestamp).toLocaleString();
|
||||||
|
const sender = msg.is_bot_message ? '🤖 Miku' : '👤 User';
|
||||||
|
const content = msg.content || '[No text content]';
|
||||||
|
|
||||||
|
const messageId = msg.message_id || msg.timestamp; // Use message_id or timestamp as identifier
|
||||||
|
const escapedContent = content.replace(/'/g, "\\'").replace(/"/g, '\\"');
|
||||||
|
|
||||||
|
// Debug: Log message details
|
||||||
|
console.log(`📝 Message ${index}: id=${messageId}, is_bot=${msg.is_bot_message}, content="${content.substring(0, 30)}..."`);
|
||||||
|
|
||||||
|
// Only show delete button for bot messages (Miku can only delete her own messages)
|
||||||
|
const deleteButton = msg.is_bot_message ?
|
||||||
|
`<button class="delete-message-btn" onclick="deleteConversation('${userId}', '${messageId}', '${escapedContent}')"
|
||||||
|
style="background: #f44336; color: white; border: none; padding: 2px 6px; font-size: 12px; border-radius: 3px; margin-left: 10px;"
|
||||||
|
title="Delete this Miku message (ID: ${messageId})">
|
||||||
|
🗑️ Delete
|
||||||
|
</button>` : '';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="conversation-message ${msg.is_bot_message ? 'bot-message' : 'user-message'}">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="sender">${sender}</span>
|
||||||
|
<span class="timestamp">${timestamp}</span>
|
||||||
|
${deleteButton}
|
||||||
|
</div>
|
||||||
|
<div class="message-content">${content}</div>
|
||||||
|
${msg.attachments && msg.attachments.length > 0 ? `
|
||||||
|
<div class="message-attachments">
|
||||||
|
<strong>📎 Attachments:</strong>
|
||||||
|
${msg.attachments.map(att => `
|
||||||
|
<div class="attachment">
|
||||||
|
- ${att.filename} (${att.size} bytes)
|
||||||
|
<a href="${att.url}" target="_blank">🔗 View</a>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${msg.reactions && msg.reactions.length > 0 ? `
|
||||||
|
<div class="message-reactions">
|
||||||
|
${msg.reactions.map(reaction => {
|
||||||
|
const reactionTime = new Date(reaction.added_at).toLocaleString();
|
||||||
|
const reactorType = reaction.is_bot ? 'bot-reaction' : 'user-reaction';
|
||||||
|
const reactorLabel = reaction.is_bot ? '🤖 Miku' : `👤 ${reaction.reactor_name}`;
|
||||||
|
return `
|
||||||
|
<div class="reaction-item" title="${reactorLabel} reacted at ${reactionTime}">
|
||||||
|
<span class="reaction-emoji">${reaction.emoji}</span>
|
||||||
|
<span class="reaction-by ${reactorType}">${reactorLabel}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('🎨 Generated HTML:', html);
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportUserDMs(userId) {
|
||||||
|
try {
|
||||||
|
// Ensure userId is always treated as a string
|
||||||
|
const userIdStr = String(userId);
|
||||||
|
await apiCall(`/dms/users/${userIdStr}/export?format=txt`);
|
||||||
|
showNotification(`DM export completed for user ${userIdStr}`);
|
||||||
|
// You could trigger a download here if the file is accessible
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export user DMs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUserDMs(userId) {
|
||||||
|
// Ensure userId is always treated as a string
|
||||||
|
const userIdStr = String(userId);
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to delete all DM logs for user ${userIdStr}? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall(`/dms/users/${userIdStr}`, 'DELETE');
|
||||||
|
showNotification(`Deleted DM logs for user ${userIdStr}`);
|
||||||
|
loadDMUsers(); // Refresh the list
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete user DMs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== User Blocking & Advanced Deletion Functions ==========
|
||||||
|
|
||||||
|
async function blockUser(userId, username) {
|
||||||
|
const userIdStr = String(userId);
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to block ${username} (${userIdStr}) from sending DMs to Miku?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall(`/dms/users/${userIdStr}/block`, 'POST');
|
||||||
|
showNotification(`${username} has been blocked from sending DMs`);
|
||||||
|
loadDMUsers(); // Refresh the list
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to block user:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unblockUser(userId, username) {
|
||||||
|
const userIdStr = String(userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall(`/dms/users/${userIdStr}/unblock`, 'POST');
|
||||||
|
showNotification(`${username} has been unblocked`);
|
||||||
|
loadBlockedUsers(); // Refresh blocked users list
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unblock user:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAllUserConversations(userId, username) {
|
||||||
|
const userIdStr = String(userId);
|
||||||
|
|
||||||
|
if (!confirm(`⚠️ DELETE ALL CONVERSATIONS with ${username} (${userIdStr})?\n\nThis will:\n• Delete ALL Miku messages from Discord DM\n• Clear all conversation logs\n• Keep the user record\n\nThis action CANNOT be undone!\n\nClick OK to confirm deletion.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall(`/dms/users/${userIdStr}/conversations/delete-all`, 'POST');
|
||||||
|
showNotification(`Bulk deletion queued for ${username} (deleting all Miku messages from Discord and logs)`);
|
||||||
|
setTimeout(() => {
|
||||||
|
loadDMUsers(); // Refresh after a delay to allow deletion to process
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete conversations:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUserCompletely(userId, username) {
|
||||||
|
const userIdStr = String(userId);
|
||||||
|
|
||||||
|
if (!confirm(`🚨 COMPLETELY DELETE USER ${username} (${userIdStr})?\n\nThis will:\n• Delete ALL conversation history\n• Delete the entire user log file\n• Remove ALL traces of this user\n\nThis action is PERMANENT and CANNOT be undone!\n\nType "${username}" below to confirm:`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmName = prompt(`Type the username "${username}" to confirm complete deletion:`);
|
||||||
|
if (confirmName !== username) {
|
||||||
|
showNotification('Deletion cancelled - username did not match', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall(`/dms/users/${userIdStr}/delete-completely`, 'POST');
|
||||||
|
showNotification(`${username} has been completely deleted from the system`);
|
||||||
|
loadDMUsers(); // Refresh the list
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete user completely:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteConversation(userId, conversationId, messageContent) {
|
||||||
|
const userIdStr = String(userId);
|
||||||
|
|
||||||
|
if (!confirm(`Delete this Miku message from Discord and logs?\n\n"${messageContent.substring(0, 100)}${messageContent.length > 100 ? '...' : ''}"\n\nThis will:\n• Delete the message from Discord DM\n• Remove it from conversation logs\n\nNote: Only Miku's messages can be deleted.\nThis action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall(`/dms/users/${userIdStr}/conversations/${conversationId}/delete`, 'POST');
|
||||||
|
showNotification('Miku message deletion queued (deleting from both Discord and logs)');
|
||||||
|
setTimeout(() => {
|
||||||
|
viewUserConversations(userId); // Refresh after a short delay to allow deletion to process
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete conversation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function analyzeUserInteraction(userId, username) {
|
||||||
|
const userIdStr = String(userId);
|
||||||
|
|
||||||
|
if (!confirm(`Run DM interaction analysis for ${username}?\n\nThis will:\n• Analyze their messages from the last 24 hours\n• Generate a sentiment report\n• Send report to bot owner\n\nMinimum 3 messages required for analysis.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
showNotification(`Analyzing ${username}'s interactions...`, 'info');
|
||||||
|
|
||||||
|
const result = await apiCall(`/dms/users/${userIdStr}/analyze`, 'POST');
|
||||||
|
|
||||||
|
if (result.reported) {
|
||||||
|
showNotification(`✅ Analysis complete! Report sent to bot owner for ${username}`);
|
||||||
|
} else {
|
||||||
|
showNotification(`📊 Analysis complete for ${username} (not enough messages or already reported today)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to analyze user:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDailyAnalysis() {
|
||||||
|
if (!confirm('Run the daily DM interaction analysis now?\n\nThis will:\n• Analyze all DM users from the last 24 hours\n• Report one significant interaction to the bot owner\n• Skip users already reported today\n\nNote: This runs automatically at 2 AM daily.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
showNotification('Starting DM interaction analysis...', 'info');
|
||||||
|
|
||||||
|
await apiCall('/dms/analysis/run', 'POST');
|
||||||
|
showNotification('✅ DM analysis completed! Check bot owner\'s DMs for any reports.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to run DM analysis:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewAnalysisReports() {
|
||||||
|
try {
|
||||||
|
showNotification('Loading analysis reports...', 'info');
|
||||||
|
|
||||||
|
const result = await apiCall('/dms/analysis/reports?limit=50');
|
||||||
|
displayAnalysisReports(result.reports);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load reports:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayAnalysisReports(reports) {
|
||||||
|
const container = document.getElementById('dm-users-list');
|
||||||
|
|
||||||
|
if (!reports || reports.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 2rem;">
|
||||||
|
<p>No analysis reports found yet.</p>
|
||||||
|
<button onclick="loadDMUsers()" style="margin-top: 1rem;">← Back to DM Users</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<button onclick="loadDMUsers()">← Back to DM Users</button>
|
||||||
|
<span style="margin-left: 1rem; color: #aaa;">${reports.length} reports found</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; gap: 1rem;">
|
||||||
|
`;
|
||||||
|
|
||||||
|
reports.forEach(report => {
|
||||||
|
const sentimentColor =
|
||||||
|
report.sentiment_score >= 5 ? '#4caf50' :
|
||||||
|
report.sentiment_score <= -3 ? '#f44336' :
|
||||||
|
'#2196f3';
|
||||||
|
|
||||||
|
const sentimentEmoji =
|
||||||
|
report.sentiment_score >= 5 ? '😊' :
|
||||||
|
report.sentiment_score <= -3 ? '😢' :
|
||||||
|
'😐';
|
||||||
|
|
||||||
|
const timestamp = new Date(report.analyzed_at).toLocaleString();
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div style="background: #2a2a2a; border-left: 4px solid ${sentimentColor}; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
||||||
|
<div>
|
||||||
|
<h4 style="margin: 0 0 0.25rem 0;">${sentimentEmoji} ${report.username}</h4>
|
||||||
|
<p style="margin: 0; font-size: 0.85rem; color: #aaa;">User ID: ${report.user_id}</p>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<div style="font-size: 1.2rem; font-weight: bold; color: ${sentimentColor};">
|
||||||
|
${report.sentiment_score > 0 ? '+' : ''}${report.sentiment_score}/10
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.75rem; color: #aaa; text-transform: uppercase;">
|
||||||
|
${report.overall_sentiment}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 0.75rem 0; padding: 0.75rem; background: #1e1e1e; border-radius: 4px;">
|
||||||
|
<strong>Miku's Feelings:</strong>
|
||||||
|
<p style="margin: 0.5rem 0 0 0; font-style: italic;">"${report.your_feelings}"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${report.notable_moment ? `
|
||||||
|
<div style="margin: 0.75rem 0; padding: 0.75rem; background: #1e1e1e; border-radius: 4px;">
|
||||||
|
<strong>Notable Moment:</strong>
|
||||||
|
<p style="margin: 0.5rem 0 0 0; font-style: italic;">"${report.notable_moment}"</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${report.key_behaviors && report.key_behaviors.length > 0 ? `
|
||||||
|
<div style="margin: 0.75rem 0;">
|
||||||
|
<strong>Key Behaviors:</strong>
|
||||||
|
<ul style="margin: 0.5rem 0 0 0; padding-left: 1.5rem;">
|
||||||
|
${report.key_behaviors.slice(0, 5).map(b => `<li>${b}</li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #444; font-size: 0.8rem; color: #aaa;">
|
||||||
|
<span>📅 ${timestamp}</span>
|
||||||
|
<span style="margin-left: 1rem;">💬 ${report.message_count} messages analyzed</span>
|
||||||
|
<span style="margin-left: 1rem;">📄 ${report.filename}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBlockedUsers() {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/dms/blocked-users');
|
||||||
|
// Hide DM users list and show blocked users section
|
||||||
|
document.getElementById('dm-users-list').style.display = 'none';
|
||||||
|
document.getElementById('blocked-users-section').style.display = 'block';
|
||||||
|
displayBlockedUsers(result.blocked_users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load blocked users:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideBlockedUsers() {
|
||||||
|
// Show DM users list and hide blocked users section
|
||||||
|
document.getElementById('dm-users-list').style.display = 'block';
|
||||||
|
document.getElementById('blocked-users-section').style.display = 'none';
|
||||||
|
loadDMUsers(); // Refresh DM users
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayBlockedUsers(blockedUsers) {
|
||||||
|
const container = document.getElementById('blocked-users-list');
|
||||||
|
|
||||||
|
if (!blockedUsers || blockedUsers.length === 0) {
|
||||||
|
container.innerHTML = '<p>No blocked users.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="blocked-users-grid">';
|
||||||
|
|
||||||
|
blockedUsers.forEach(user => {
|
||||||
|
html += `
|
||||||
|
<div class="blocked-user-card">
|
||||||
|
<h4>🚫 ${user.username}</h4>
|
||||||
|
<p><strong>ID:</strong> ${user.user_id}</p>
|
||||||
|
<p><strong>Blocked:</strong> ${new Date(user.blocked_at).toLocaleString()}</p>
|
||||||
|
<p><strong>Blocked by:</strong> ${user.blocked_by}</p>
|
||||||
|
<div class="blocked-user-actions">
|
||||||
|
<button onclick="unblockUser('${user.user_id}', '${user.username}')" style="background: #4caf50;">✅ Unblock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAllDMs() {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/dms/users');
|
||||||
|
|
||||||
|
let exportCount = 0;
|
||||||
|
for (const user of (result.users || [])) {
|
||||||
|
try {
|
||||||
|
await exportUserDMs(user.user_id);
|
||||||
|
exportCount++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to export DMs for user ${user.user_id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showNotification(`Exported DMs for ${exportCount} users`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export all DMs:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
bot/static/js/image-gen.js
Normal file
127
bot/static/js/image-gen.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Miku Control Panel — Image Generation Module
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function checkImageSystemStatus() {
|
||||||
|
try {
|
||||||
|
const statusDisplay = document.getElementById('image-status-display');
|
||||||
|
statusDisplay.innerHTML = '🔄 Checking system status...';
|
||||||
|
|
||||||
|
const result = await apiCall('/image/status');
|
||||||
|
|
||||||
|
const workflowStatus = result.workflow_template_exists ? '✅ Found' : '❌ Missing';
|
||||||
|
const comfyuiStatus = result.comfyui_running ? '✅ Running' : '❌ Not running';
|
||||||
|
|
||||||
|
statusDisplay.innerHTML = `
|
||||||
|
<strong>System Status:</strong>
|
||||||
|
• Workflow Template (Miku_BasicWorkflow.json): ${workflowStatus}
|
||||||
|
• ComfyUI Server: ${comfyuiStatus}
|
||||||
|
${result.comfyui_running ? `• Detected ComfyUI URL: ${result.comfyui_url}` : ''}
|
||||||
|
|
||||||
|
<strong>Overall Status:</strong> ${result.ready ? '✅ Ready for image generation' : '⚠️ Setup required'}
|
||||||
|
|
||||||
|
${!result.workflow_template_exists ? '⚠️ Place Miku_BasicWorkflow.json in bot directory\n' : ''}${!result.comfyui_running ? '⚠️ Start ComfyUI server on localhost:8188 (bot will auto-detect correct URL)\n' : ''}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check image system status:', error);
|
||||||
|
document.getElementById('image-status-display').innerHTML = `❌ Error: ${error.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testImageDetection() {
|
||||||
|
const message = document.getElementById('detection-test-message').value.trim();
|
||||||
|
const resultsDiv = document.getElementById('detection-test-results');
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
resultsDiv.innerHTML = '❌ Please enter a test message';
|
||||||
|
resultsDiv.style.color = 'red';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
resultsDiv.innerHTML = '🔍 Testing detection...';
|
||||||
|
resultsDiv.style.color = '#4CAF50';
|
||||||
|
|
||||||
|
const result = await apiCall('/image/test-detection', 'POST', { message: message });
|
||||||
|
|
||||||
|
const detectionIcon = result.is_image_request ? '✅' : '❌';
|
||||||
|
const detectionText = result.is_image_request ? 'WILL trigger image generation' : 'will NOT trigger image generation';
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<strong>Detection Result:</strong> ${detectionIcon} This message ${detectionText}
|
||||||
|
${result.is_image_request ? `<br><strong>Extracted Prompt:</strong> "${result.extracted_prompt}"` : ''}
|
||||||
|
<br><strong>Original Message:</strong> "${result.original_message}"`;
|
||||||
|
|
||||||
|
resultsDiv.style.color = result.is_image_request ? '#4CAF50' : '#ff9800';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to test image detection:', error);
|
||||||
|
resultsDiv.innerHTML = `❌ Error: ${error.message}`;
|
||||||
|
resultsDiv.style.color = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateManualImage() {
|
||||||
|
const prompt = document.getElementById('manual-image-prompt').value.trim();
|
||||||
|
const statusDiv = document.getElementById('manual-image-status');
|
||||||
|
const previewDiv = document.getElementById('manual-image-preview');
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
statusDiv.innerHTML = '❌ Please enter an image prompt';
|
||||||
|
statusDiv.style.color = 'red';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
previewDiv.innerHTML = '';
|
||||||
|
|
||||||
|
statusDiv.innerHTML = '🎨 Generating image... This may take a few minutes.';
|
||||||
|
statusDiv.style.color = '#4CAF50';
|
||||||
|
|
||||||
|
const result = await apiCall('/image/generate', 'POST', { prompt: prompt });
|
||||||
|
|
||||||
|
statusDiv.innerHTML = `✅ Image generated successfully!`;
|
||||||
|
statusDiv.style.color = '#4CAF50';
|
||||||
|
|
||||||
|
if (result.image_path) {
|
||||||
|
const filename = result.image_path.split('/').pop();
|
||||||
|
const imageUrl = `/image/view/${encodeURIComponent(filename)}`;
|
||||||
|
|
||||||
|
const imgContainer = document.createElement('div');
|
||||||
|
imgContainer.style.cssText = 'background: #1e1e1e; padding: 1rem; border-radius: 8px; border: 1px solid #333;';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = imageUrl;
|
||||||
|
img.alt = 'Generated Image';
|
||||||
|
img.style.cssText = 'max-width: 100%; max-height: 600px; border-radius: 4px; display: block; margin: 0 auto;';
|
||||||
|
|
||||||
|
img.onload = function() {
|
||||||
|
console.log('Image loaded successfully:', imageUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = function() {
|
||||||
|
console.error('Failed to load image:', imageUrl);
|
||||||
|
imgContainer.innerHTML = `
|
||||||
|
<div style="color: #f44336; padding: 1rem; text-align: center;">
|
||||||
|
❌ Failed to load image<br>
|
||||||
|
<span style="font-size: 0.85rem;">Path: ${result.image_path}</span><br>
|
||||||
|
<span style="font-size: 0.85rem;">URL: ${imageUrl}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
imgContainer.appendChild(img);
|
||||||
|
|
||||||
|
const pathInfo = document.createElement('div');
|
||||||
|
pathInfo.style.cssText = 'margin-top: 0.5rem; color: #aaa; font-size: 0.85rem; text-align: center;';
|
||||||
|
pathInfo.innerHTML = `<strong>File:</strong> ${filename}`;
|
||||||
|
imgContainer.appendChild(pathInfo);
|
||||||
|
|
||||||
|
previewDiv.appendChild(imgContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('manual-image-prompt').value = '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate image:', error);
|
||||||
|
statusDiv.innerHTML = `❌ Error: ${error.message}`;
|
||||||
|
statusDiv.style.color = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
446
bot/static/js/memories.js
Normal file
446
bot/static/js/memories.js
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Miku Control Panel — Memory Management Module
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function refreshMemoryStats() {
|
||||||
|
try {
|
||||||
|
// Fetch Cat status
|
||||||
|
const statusData = await apiCall('/memory/status');
|
||||||
|
|
||||||
|
const indicator = document.getElementById('cat-status-indicator');
|
||||||
|
const toggleBtn = document.getElementById('cat-toggle-btn');
|
||||||
|
|
||||||
|
if (statusData.healthy) {
|
||||||
|
indicator.innerHTML = `<span style="color: #6fdc6f;">● Connected</span> — ${statusData.url}`;
|
||||||
|
} else {
|
||||||
|
indicator.innerHTML = `<span style="color: #ff6b6b;">● Disconnected</span> — ${statusData.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusData.circuit_breaker_active) {
|
||||||
|
indicator.innerHTML += ` <span style="color: #dcb06f;">(circuit breaker active)</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBtn.textContent = statusData.enabled ? '🐱 Cat: ON' : '😿 Cat: OFF';
|
||||||
|
toggleBtn.style.background = statusData.enabled ? '#2a7a2a' : '#7a2a2a';
|
||||||
|
toggleBtn.style.borderColor = statusData.enabled ? '#4a9a4a' : '#9a4a4a';
|
||||||
|
|
||||||
|
// Fetch memory stats
|
||||||
|
const statsData = await apiCall('/memory/stats');
|
||||||
|
|
||||||
|
if (statsData.success && statsData.collections) {
|
||||||
|
const collections = {};
|
||||||
|
statsData.collections.forEach(c => { collections[c.name] = c.vectors_count; });
|
||||||
|
|
||||||
|
document.getElementById('stat-episodic-count').textContent = collections['episodic'] ?? '—';
|
||||||
|
document.getElementById('stat-declarative-count').textContent = collections['declarative'] ?? '—';
|
||||||
|
document.getElementById('stat-procedural-count').textContent = collections['procedural'] ?? '—';
|
||||||
|
} else {
|
||||||
|
document.getElementById('stat-episodic-count').textContent = '—';
|
||||||
|
document.getElementById('stat-declarative-count').textContent = '—';
|
||||||
|
document.getElementById('stat-procedural-count').textContent = '—';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error refreshing memory stats:', err);
|
||||||
|
document.getElementById('cat-status-indicator').innerHTML = '<span style="color: #ff6b6b;">● Error checking status</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCatIntegration() {
|
||||||
|
try {
|
||||||
|
const statusData = await apiCall('/memory/status');
|
||||||
|
const newState = !statusData.enabled;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('enabled', newState);
|
||||||
|
const res = await fetch('/memory/toggle', { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showNotification(`Cheshire Cat ${newState ? 'enabled' : 'disabled'}`, newState ? 'success' : 'info');
|
||||||
|
refreshMemoryStats();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showNotification('Failed to toggle Cat integration', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerConsolidation() {
|
||||||
|
const btn = document.getElementById('consolidate-btn');
|
||||||
|
const status = document.getElementById('consolidation-status');
|
||||||
|
const resultDiv = document.getElementById('consolidation-result');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Running...';
|
||||||
|
status.textContent = 'Consolidation in progress (this may take a few minutes)...';
|
||||||
|
resultDiv.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/memory/consolidate', 'POST');
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
status.textContent = '✅ Consolidation complete!';
|
||||||
|
status.style.color = '#6fdc6f';
|
||||||
|
resultDiv.textContent = data.result || 'Consolidation finished successfully.';
|
||||||
|
resultDiv.style.display = 'block';
|
||||||
|
showNotification('Memory consolidation complete', 'success');
|
||||||
|
refreshMemoryStats();
|
||||||
|
} else {
|
||||||
|
status.textContent = '❌ ' + (data.error || 'Consolidation failed');
|
||||||
|
status.style.color = '#ff6b6b';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = '❌ Error: ' + err.message;
|
||||||
|
status.style.color = '#ff6b6b';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🌙 Run Consolidation';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFacts() {
|
||||||
|
const listDiv = document.getElementById('facts-list');
|
||||||
|
listDiv.innerHTML = '<div style="text-align: center; color: #888; padding: 1rem;">Loading facts...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/memory/facts');
|
||||||
|
|
||||||
|
if (!data.success || data.count === 0) {
|
||||||
|
listDiv.innerHTML = '<div style="text-align: center; color: #666; padding: 2rem;">No declarative facts stored yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
data.facts.forEach((fact, i) => {
|
||||||
|
const source = fact.metadata?.source || 'unknown';
|
||||||
|
const when = fact.metadata?.when ? new Date(fact.metadata.when * 1000).toLocaleString() : 'unknown';
|
||||||
|
const factDataJson = escapeJsonForAttribute(fact);
|
||||||
|
html += `
|
||||||
|
<div class="memory-item" style="background: #242424; padding: 0.6rem 0.8rem; margin-bottom: 0.4rem; border-radius: 4px; border-left: 3px solid #2a9955; display: flex; justify-content: space-between; align-items: flex-start;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(fact.content)}</div>
|
||||||
|
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
|
||||||
|
Source: ${escapeHtml(source)} · ${when}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.3rem; flex-shrink: 0;">
|
||||||
|
<button data-memory='${factDataJson}' onclick='showEditMemoryModalFromButton(this, "declarative", "${fact.id}")'
|
||||||
|
style="background: none; border: none; color: #5599cc; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
|
||||||
|
title="Edit this fact">✏️</button>
|
||||||
|
<button onclick="deleteMemoryPoint('declarative', '${fact.id}', this)"
|
||||||
|
style="background: none; border: none; color: #993333; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
|
||||||
|
title="Delete this fact">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
listDiv.innerHTML = `<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">${data.count} facts loaded</div>` + html;
|
||||||
|
} catch (err) {
|
||||||
|
listDiv.innerHTML = `<div style="color: #ff6b6b; padding: 1rem;">Error loading facts: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEpisodicMemories() {
|
||||||
|
const listDiv = document.getElementById('episodic-list');
|
||||||
|
listDiv.innerHTML = '<div style="text-align: center; color: #888; padding: 1rem;">Loading memories...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/memory/episodic');
|
||||||
|
|
||||||
|
if (!data.success || data.count === 0) {
|
||||||
|
listDiv.innerHTML = '<div style="text-align: center; color: #666; padding: 2rem;">No episodic memories stored yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
data.memories.forEach((mem, i) => {
|
||||||
|
const source = mem.metadata?.source || 'unknown';
|
||||||
|
const when = mem.metadata?.when ? new Date(mem.metadata.when * 1000).toLocaleString() : 'unknown';
|
||||||
|
const memDataJson = escapeJsonForAttribute(mem);
|
||||||
|
html += `
|
||||||
|
<div class="memory-item" style="background: #242424; padding: 0.6rem 0.8rem; margin-bottom: 0.4rem; border-radius: 4px; border-left: 3px solid #2a5599; display: flex; justify-content: space-between; align-items: flex-start;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="color: #ddd; font-size: 0.9rem;">${escapeHtml(mem.content)}</div>
|
||||||
|
<div style="color: #666; font-size: 0.75rem; margin-top: 0.3rem;">
|
||||||
|
Source: ${escapeHtml(source)} · ${when}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 0.3rem; flex-shrink: 0;">
|
||||||
|
<button data-memory='${memDataJson}' onclick='showEditMemoryModalFromButton(this, "episodic", "${mem.id}")'
|
||||||
|
style="background: none; border: none; color: #5599cc; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
|
||||||
|
title="Edit this memory">✏️</button>
|
||||||
|
<button onclick="deleteMemoryPoint('episodic', '${mem.id}', this)"
|
||||||
|
style="background: none; border: none; color: #993333; cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.85rem;"
|
||||||
|
title="Delete this memory">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
listDiv.innerHTML = `<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">${data.count} memories loaded</div>` + html;
|
||||||
|
} catch (err) {
|
||||||
|
listDiv.innerHTML = `<div style="color: #ff6b6b; padding: 1rem;">Error loading memories: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMemoryPoint(collection, pointId, btnElement) {
|
||||||
|
if (!confirm(`Delete this ${collection} memory point?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiCall(`/memory/point/${collection}/${pointId}`, 'DELETE');
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Remove the row from the UI
|
||||||
|
const row = btnElement.closest('div[style*="margin-bottom"]');
|
||||||
|
if (row) row.remove();
|
||||||
|
showNotification('Memory point deleted', 'success');
|
||||||
|
refreshMemoryStats();
|
||||||
|
} else {
|
||||||
|
showNotification('Failed to delete: ' + (data.error || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete memory point:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete All Memories — Multi-step confirmation flow
|
||||||
|
function onDeleteStep1Change() {
|
||||||
|
const checked = document.getElementById('delete-checkbox-1').checked;
|
||||||
|
document.getElementById('delete-step-2').style.display = checked ? 'block' : 'none';
|
||||||
|
if (!checked) {
|
||||||
|
document.getElementById('delete-checkbox-2').checked = false;
|
||||||
|
document.getElementById('delete-step-3').style.display = 'none';
|
||||||
|
document.getElementById('delete-step-final').style.display = 'none';
|
||||||
|
document.getElementById('delete-confirmation-input').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteStep2Change() {
|
||||||
|
const checked = document.getElementById('delete-checkbox-2').checked;
|
||||||
|
document.getElementById('delete-step-3').style.display = checked ? 'block' : 'none';
|
||||||
|
document.getElementById('delete-step-final').style.display = checked ? 'block' : 'none';
|
||||||
|
if (!checked) {
|
||||||
|
document.getElementById('delete-confirmation-input').value = '';
|
||||||
|
updateDeleteButton();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteInputChange() {
|
||||||
|
updateDeleteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDeleteButton() {
|
||||||
|
const input = document.getElementById('delete-confirmation-input').value;
|
||||||
|
const expected = "Yes, I am deleting Miku's memories fully.";
|
||||||
|
const btn = document.getElementById('delete-all-btn');
|
||||||
|
const match = input === expected;
|
||||||
|
|
||||||
|
btn.disabled = !match;
|
||||||
|
btn.style.cursor = match ? 'pointer' : 'not-allowed';
|
||||||
|
btn.style.opacity = match ? '1' : '0.5';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDeleteAllMemories() {
|
||||||
|
const input = document.getElementById('delete-confirmation-input').value;
|
||||||
|
const expected = "Yes, I am deleting Miku's memories fully.";
|
||||||
|
|
||||||
|
if (input !== expected) {
|
||||||
|
showNotification('Confirmation string does not match', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('delete-all-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Deleting...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/memory/delete', 'POST', { confirmation: input });
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showNotification('All memories have been permanently deleted', 'success');
|
||||||
|
resetDeleteFlow();
|
||||||
|
refreshMemoryStats();
|
||||||
|
} else {
|
||||||
|
showNotification('Deletion failed: ' + (data.error || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete all memories:', err);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🗑️ Permanently Delete All Memories';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDeleteFlow() {
|
||||||
|
document.getElementById('delete-checkbox-1').checked = false;
|
||||||
|
document.getElementById('delete-checkbox-2').checked = false;
|
||||||
|
document.getElementById('delete-confirmation-input').value = '';
|
||||||
|
document.getElementById('delete-step-2').style.display = 'none';
|
||||||
|
document.getElementById('delete-step-3').style.display = 'none';
|
||||||
|
document.getElementById('delete-step-final').style.display = 'none';
|
||||||
|
updateDeleteButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory Edit/Create Modal Functions
|
||||||
|
// currentEditMemory declared in core.js
|
||||||
|
|
||||||
|
function showEditMemoryModalFromButton(button, collection, pointId) {
|
||||||
|
const memoryJson = button.getAttribute('data-memory');
|
||||||
|
// Unescape HTML entities back to JSON
|
||||||
|
const unescapedJson = memoryJson
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
const memory = JSON.parse(unescapedJson);
|
||||||
|
showEditMemoryModal(collection, pointId, memory);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditMemoryModal(collection, pointId, memoryData) {
|
||||||
|
const memory = typeof memoryData === 'string' ? JSON.parse(memoryData) : memoryData;
|
||||||
|
currentEditMemory = { collection, pointId, memory };
|
||||||
|
|
||||||
|
const modal = document.getElementById('edit-memory-modal');
|
||||||
|
const contentField = document.getElementById('edit-memory-content');
|
||||||
|
const sourceField = document.getElementById('edit-memory-source');
|
||||||
|
|
||||||
|
contentField.value = memory.content || '';
|
||||||
|
sourceField.value = memory.metadata?.source || '';
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditMemoryModal() {
|
||||||
|
document.getElementById('edit-memory-modal').style.display = 'none';
|
||||||
|
currentEditMemory = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMemoryEdit() {
|
||||||
|
if (!currentEditMemory) return;
|
||||||
|
|
||||||
|
const content = document.getElementById('edit-memory-content').value.trim();
|
||||||
|
const source = document.getElementById('edit-memory-source').value.trim();
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
showNotification('Content cannot be empty', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collection, pointId } = currentEditMemory;
|
||||||
|
const saveBtn = document.querySelector('#edit-memory-modal button[onclick="saveMemoryEdit()"]');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiCall(`/memory/point/${collection}/${pointId}`, 'PUT', {
|
||||||
|
content: content,
|
||||||
|
metadata: { source: source || 'manual_edit' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showNotification('Memory updated successfully', 'success');
|
||||||
|
closeEditMemoryModal();
|
||||||
|
// Reload the appropriate list
|
||||||
|
if (collection === 'declarative') {
|
||||||
|
loadFacts();
|
||||||
|
} else if (collection === 'episodic') {
|
||||||
|
loadEpisodicMemories();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showNotification('Failed to update: ' + (data.error || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save memory edit:', err);
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save Changes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateMemoryModal(collection) {
|
||||||
|
const modal = document.getElementById('create-memory-modal');
|
||||||
|
document.getElementById('create-memory-collection').value = collection;
|
||||||
|
document.getElementById('create-memory-content').value = '';
|
||||||
|
document.getElementById('create-memory-user-id').value = '';
|
||||||
|
document.getElementById('create-memory-source').value = 'manual';
|
||||||
|
|
||||||
|
// Update modal title based on collection type
|
||||||
|
const title = collection === 'declarative' ? 'Add New Fact' : 'Add New Memory';
|
||||||
|
document.querySelector('#create-memory-modal h3').textContent = title;
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateMemoryModal() {
|
||||||
|
document.getElementById('create-memory-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal keyboard and backdrop close handlers
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const editModal = document.getElementById('edit-memory-modal');
|
||||||
|
const createModal = document.getElementById('create-memory-modal');
|
||||||
|
if (editModal && editModal.style.display !== 'none') closeEditMemoryModal();
|
||||||
|
if (createModal && createModal.style.display !== 'none') closeCreateMemoryModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveNewMemory() {
|
||||||
|
const collection = document.getElementById('create-memory-collection').value;
|
||||||
|
const content = document.getElementById('create-memory-content').value.trim();
|
||||||
|
const userId = document.getElementById('create-memory-user-id').value.trim();
|
||||||
|
const source = document.getElementById('create-memory-source').value.trim();
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
showNotification('Content cannot be empty', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBtn = document.querySelector('#create-memory-modal button[onclick="saveNewMemory()"]');
|
||||||
|
createBtn.disabled = true;
|
||||||
|
createBtn.textContent = 'Creating...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/memory/create', 'POST', {
|
||||||
|
collection: collection,
|
||||||
|
content: content,
|
||||||
|
user_id: userId || null,
|
||||||
|
source: source || 'manual',
|
||||||
|
metadata: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showNotification(`${collection === 'declarative' ? 'Fact' : 'Memory'} created successfully`, 'success');
|
||||||
|
closeCreateMemoryModal();
|
||||||
|
// Reload the appropriate list
|
||||||
|
if (collection === 'declarative') {
|
||||||
|
loadFacts();
|
||||||
|
} else if (collection === 'episodic') {
|
||||||
|
loadEpisodicMemories();
|
||||||
|
}
|
||||||
|
refreshMemoryStats();
|
||||||
|
} else {
|
||||||
|
showNotification('Failed to create: ' + (data.error || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save new memory:', err);
|
||||||
|
} finally {
|
||||||
|
createBtn.disabled = false;
|
||||||
|
createBtn.textContent = 'Create Memory';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search/Filter Function
|
||||||
|
function filterMemories(listId, searchTerm) {
|
||||||
|
const listDiv = document.getElementById(listId);
|
||||||
|
const items = listDiv.querySelectorAll('.memory-item');
|
||||||
|
const term = searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const content = item.textContent.toLowerCase();
|
||||||
|
if (term === '' || content.includes(term)) {
|
||||||
|
item.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
396
bot/static/js/modes.js
Normal file
396
bot/static/js/modes.js
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Miku Control Panel — Modes Module
|
||||||
|
// Evil Mode, GPU Selection, Bipolar Mode
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ===== Evil Mode Functions =====
|
||||||
|
|
||||||
|
async function checkEvilModeStatus() {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/evil-mode');
|
||||||
|
evilMode = result.evil_mode;
|
||||||
|
updateEvilModeUI();
|
||||||
|
|
||||||
|
if (evilMode && result.mood) {
|
||||||
|
const moodSelect = document.getElementById('mood');
|
||||||
|
moodSelect.value = result.mood;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check evil mode status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEvilMode() {
|
||||||
|
try {
|
||||||
|
const toggleBtn = document.getElementById('evil-mode-toggle');
|
||||||
|
toggleBtn.disabled = true;
|
||||||
|
toggleBtn.textContent = '⏳ Switching...';
|
||||||
|
|
||||||
|
const result = await apiCall('/evil-mode/toggle', 'POST');
|
||||||
|
evilMode = result.evil_mode;
|
||||||
|
updateEvilModeUI();
|
||||||
|
|
||||||
|
if (evilMode) {
|
||||||
|
showNotification('😈 Evil Mode enabled! Evil Miku has awakened...');
|
||||||
|
} else {
|
||||||
|
showNotification('🎤 Evil Mode disabled. Normal Miku is back!');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle evil mode:', error);
|
||||||
|
showNotification('Failed to toggle evil mode: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEvilModeUI() {
|
||||||
|
const body = document.body;
|
||||||
|
const title = document.getElementById('panel-title');
|
||||||
|
const toggleBtn = document.getElementById('evil-mode-toggle');
|
||||||
|
const moodSelect = document.getElementById('mood');
|
||||||
|
|
||||||
|
if (evilMode) {
|
||||||
|
body.classList.add('evil-mode');
|
||||||
|
title.textContent = 'Evil Miku Control Panel';
|
||||||
|
toggleBtn.textContent = '😈 Evil Mode: ON';
|
||||||
|
toggleBtn.disabled = false;
|
||||||
|
|
||||||
|
moodSelect.innerHTML = `
|
||||||
|
<option value="aggressive">👿 aggressive</option>
|
||||||
|
<option value="bored">🥱 bored</option>
|
||||||
|
<option value="contemptuous">👑 contemptuous</option>
|
||||||
|
<option value="cunning">🐍 cunning</option>
|
||||||
|
<option value="evil_neutral" selected>evil neutral</option>
|
||||||
|
<option value="jealous">💚 jealous</option>
|
||||||
|
<option value="manic">🤪 manic</option>
|
||||||
|
<option value="melancholic">🌑 melancholic</option>
|
||||||
|
<option value="playful_cruel">🎭 playful cruel</option>
|
||||||
|
<option value="sarcastic">😈 sarcastic</option>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
body.classList.remove('evil-mode');
|
||||||
|
title.textContent = 'Miku Control Panel';
|
||||||
|
toggleBtn.textContent = '😈 Evil Mode: OFF';
|
||||||
|
toggleBtn.disabled = false;
|
||||||
|
|
||||||
|
moodSelect.innerHTML = `
|
||||||
|
<option value="angry">💢 angry</option>
|
||||||
|
<option value="asleep">💤 asleep</option>
|
||||||
|
<option value="bubbly">🫧 bubbly</option>
|
||||||
|
<option value="curious">👀 curious</option>
|
||||||
|
<option value="excited">✨ excited</option>
|
||||||
|
<option value="flirty">🫦 flirty</option>
|
||||||
|
<option value="irritated">😒 irritated</option>
|
||||||
|
<option value="melancholy">🍷 melancholy</option>
|
||||||
|
<option value="neutral" selected>neutral</option>
|
||||||
|
<option value="romantic">💌 romantic</option>
|
||||||
|
<option value="serious">👔 serious</option>
|
||||||
|
<option value="shy">👉👈 shy</option>
|
||||||
|
<option value="silly">🪿 silly</option>
|
||||||
|
<option value="sleepy">🌙 sleepy</option>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBipolarToggleVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== GPU Selection Management =====
|
||||||
|
|
||||||
|
async function checkGPUStatus() {
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/gpu-status');
|
||||||
|
selectedGPU = data.gpu || 'nvidia';
|
||||||
|
updateGPUUI();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check GPU status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleGPU() {
|
||||||
|
try {
|
||||||
|
const toggleBtn = document.getElementById('gpu-selector-toggle');
|
||||||
|
toggleBtn.disabled = true;
|
||||||
|
toggleBtn.textContent = '⏳ Switching...';
|
||||||
|
|
||||||
|
const result = await apiCall('/gpu-select', 'POST', {
|
||||||
|
gpu: selectedGPU === 'nvidia' ? 'amd' : 'nvidia'
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedGPU = result.gpu;
|
||||||
|
updateGPUUI();
|
||||||
|
|
||||||
|
const gpuName = selectedGPU === 'nvidia' ? 'NVIDIA GTX 1660' : 'AMD RX 6800';
|
||||||
|
showNotification(`🎮 Switched to ${gpuName}!`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle GPU:', error);
|
||||||
|
showNotification('Failed to switch GPU: ' + error.message, 'error');
|
||||||
|
toggleBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGPUUI() {
|
||||||
|
const toggleBtn = document.getElementById('gpu-selector-toggle');
|
||||||
|
|
||||||
|
if (selectedGPU === 'amd') {
|
||||||
|
toggleBtn.textContent = '🎮 GPU: AMD';
|
||||||
|
toggleBtn.style.background = '#c91432';
|
||||||
|
toggleBtn.style.borderColor = '#e91436';
|
||||||
|
} else {
|
||||||
|
toggleBtn.textContent = '🎮 GPU: NVIDIA';
|
||||||
|
toggleBtn.style.background = '#2a5599';
|
||||||
|
toggleBtn.style.borderColor = '#4a7bc9';
|
||||||
|
}
|
||||||
|
toggleBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Bipolar Mode Management =====
|
||||||
|
|
||||||
|
async function checkBipolarModeStatus() {
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/bipolar-mode');
|
||||||
|
bipolarMode = data.bipolar_mode;
|
||||||
|
updateBipolarModeUI();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check bipolar mode status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleBipolarMode() {
|
||||||
|
try {
|
||||||
|
const toggleBtn = document.getElementById('bipolar-mode-toggle');
|
||||||
|
toggleBtn.disabled = true;
|
||||||
|
toggleBtn.textContent = '⏳ Switching...';
|
||||||
|
|
||||||
|
const result = await apiCall('/bipolar-mode/toggle', 'POST');
|
||||||
|
bipolarMode = result.bipolar_mode;
|
||||||
|
updateBipolarModeUI();
|
||||||
|
|
||||||
|
if (bipolarMode) {
|
||||||
|
showNotification('🔄 Bipolar Mode enabled! Both Mikus can now argue...');
|
||||||
|
} else {
|
||||||
|
showNotification('🔄 Bipolar Mode disabled.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle bipolar mode:', error);
|
||||||
|
showNotification('Failed to toggle bipolar mode: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBipolarModeUI() {
|
||||||
|
const toggleBtn = document.getElementById('bipolar-mode-toggle');
|
||||||
|
const bipolarSection = document.getElementById('bipolar-section');
|
||||||
|
|
||||||
|
if (bipolarMode) {
|
||||||
|
toggleBtn.textContent = '🔄 Bipolar: ON';
|
||||||
|
toggleBtn.style.background = '#9932CC';
|
||||||
|
toggleBtn.style.borderColor = '#9932CC';
|
||||||
|
toggleBtn.disabled = false;
|
||||||
|
|
||||||
|
if (bipolarSection) {
|
||||||
|
bipolarSection.style.display = 'block';
|
||||||
|
loadScoreboard();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toggleBtn.textContent = '🔄 Bipolar: OFF';
|
||||||
|
toggleBtn.style.background = '#333';
|
||||||
|
toggleBtn.style.borderColor = '#666';
|
||||||
|
toggleBtn.disabled = false;
|
||||||
|
|
||||||
|
if (bipolarSection) {
|
||||||
|
bipolarSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBipolarToggleVisibility() {
|
||||||
|
const bipolarToggle = document.getElementById('bipolar-mode-toggle');
|
||||||
|
bipolarToggle.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerPersonaDialogue() {
|
||||||
|
const messageIdInput = document.getElementById('dialogue-message-id').value.trim();
|
||||||
|
const statusDiv = document.getElementById('dialogue-status');
|
||||||
|
|
||||||
|
if (!messageIdInput) {
|
||||||
|
showNotification('Please enter a message ID', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(messageIdInput)) {
|
||||||
|
showNotification('Invalid message ID format - should be a number', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
statusDiv.innerHTML = '<span style="color: #6B8EFF;">⏳ Analyzing message for dialogue trigger...</span>';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
message_id: messageIdInput
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiCall('/bipolar-mode/trigger-dialogue', 'POST', requestBody);
|
||||||
|
|
||||||
|
if (result.status === 'error') {
|
||||||
|
statusDiv.innerHTML = `<span style="color: #ff4444;">❌ ${result.message}</span>`;
|
||||||
|
showNotification(result.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusDiv.innerHTML = `<span style="color: #00ff00;">✅ ${result.message}</span>`;
|
||||||
|
showNotification(`💬 ${result.message}`);
|
||||||
|
|
||||||
|
document.getElementById('dialogue-message-id').value = '';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
statusDiv.innerHTML = `<span style="color: #ff4444;">❌ Failed to trigger dialogue: ${error.message}</span>`;
|
||||||
|
showNotification(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerBipolarArgument() {
|
||||||
|
const channelIdInput = document.getElementById('bipolar-channel-id').value.trim();
|
||||||
|
const messageIdInput = document.getElementById('bipolar-message-id').value.trim();
|
||||||
|
const context = document.getElementById('bipolar-context').value.trim();
|
||||||
|
const statusDiv = document.getElementById('bipolar-status');
|
||||||
|
|
||||||
|
if (!channelIdInput) {
|
||||||
|
showNotification('Please enter a channel ID', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(channelIdInput)) {
|
||||||
|
showNotification('Invalid channel ID format - should be a number', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageIdInput && !/^\d+$/.test(messageIdInput)) {
|
||||||
|
showNotification('Invalid message ID format - should be a number', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
statusDiv.innerHTML = '<span style="color: #9932CC;">⏳ Triggering argument...</span>';
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
channel_id: channelIdInput,
|
||||||
|
context: context
|
||||||
|
};
|
||||||
|
|
||||||
|
if (messageIdInput) {
|
||||||
|
requestBody.message_id = messageIdInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiCall('/bipolar-mode/trigger-argument', 'POST', requestBody);
|
||||||
|
|
||||||
|
if (result.status === 'error') {
|
||||||
|
statusDiv.innerHTML = `<span style="color: #ff4444;">❌ ${result.message}</span>`;
|
||||||
|
showNotification(result.message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusDiv.innerHTML = `<span style="color: #00ff00;">✅ ${result.message}</span>`;
|
||||||
|
showNotification(`⚔️ Argument triggered!`);
|
||||||
|
|
||||||
|
document.getElementById('bipolar-context').value = '';
|
||||||
|
document.getElementById('bipolar-message-id').value = '';
|
||||||
|
|
||||||
|
loadActiveArguments();
|
||||||
|
loadScoreboard();
|
||||||
|
} catch (error) {
|
||||||
|
statusDiv.innerHTML = `<span style="color: #ff4444;">❌ ${error.message}</span>`;
|
||||||
|
showNotification('Failed to trigger argument: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScoreboard() {
|
||||||
|
const scoreboardContent = document.getElementById('scoreboard-content');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/bipolar-mode/scoreboard', 'GET');
|
||||||
|
|
||||||
|
if (result.status === 'error') {
|
||||||
|
scoreboardContent.innerHTML = `<p style="color: #ff4444;">Failed to load scoreboard</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scoreboard } = result;
|
||||||
|
const total = scoreboard.total_arguments;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
scoreboardContent.innerHTML = `<p style="color: #888;">No arguments have been judged yet.</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mikuPct = total > 0 ? ((scoreboard.miku_wins / total) * 100).toFixed(1) : 0;
|
||||||
|
const evilPct = total > 0 ? ((scoreboard.evil_wins / total) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 0.8rem;">
|
||||||
|
<div style="text-align: center; flex: 1;">
|
||||||
|
<div style="color: #86cecb; font-size: 1.2rem; font-weight: bold;">${scoreboard.miku_wins}</div>
|
||||||
|
<div style="color: #888; font-size: 0.85rem;">Hatsune Miku</div>
|
||||||
|
<div style="color: #999; font-size: 0.75rem;">${mikuPct}%</div>
|
||||||
|
</div>
|
||||||
|
<div style="align-self: center; color: #666; font-size: 1.2rem;">vs</div>
|
||||||
|
<div style="text-align: center; flex: 1;">
|
||||||
|
<div style="color: #D60004; font-size: 1.2rem; font-weight: bold;">${scoreboard.evil_wins}</div>
|
||||||
|
<div style="color: #888; font-size: 0.85rem;">Evil Miku</div>
|
||||||
|
<div style="color: #999; font-size: 0.75rem;">${evilPct}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center; color: #aaa; font-size: 0.85rem; border-top: 1px solid #333; padding-top: 0.5rem;">
|
||||||
|
Total Arguments: ${total}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (scoreboard.history && scoreboard.history.length > 0) {
|
||||||
|
html += `<div style="margin-top: 0.8rem; padding-top: 0.8rem; border-top: 1px solid #333;">
|
||||||
|
<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.3rem;">Recent Results:</div>`;
|
||||||
|
|
||||||
|
scoreboard.history.reverse().forEach(entry => {
|
||||||
|
const winnerName = entry.winner === 'evil' ? 'Evil Miku' : 'Hatsune Miku';
|
||||||
|
const winnerColor = entry.winner === 'evil' ? '#D60004' : '#86cecb';
|
||||||
|
const date = new Date(entry.timestamp).toLocaleString();
|
||||||
|
|
||||||
|
html += `<div style="font-size: 0.75rem; color: #666; margin-bottom: 0.2rem;">
|
||||||
|
<span style="color: ${winnerColor};">🏆 ${winnerName}</span> (${entry.exchanges} exchanges) - ${date}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
scoreboardContent.innerHTML = html;
|
||||||
|
} catch (error) {
|
||||||
|
scoreboardContent.innerHTML = `<p style="color: #ff4444;">Error loading scoreboard</p>`;
|
||||||
|
console.error('Scoreboard error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveArguments() {
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/bipolar-mode/arguments');
|
||||||
|
const container = document.getElementById('active-arguments');
|
||||||
|
const list = document.getElementById('active-arguments-list');
|
||||||
|
|
||||||
|
if (Object.keys(data.active_arguments).length > 0) {
|
||||||
|
container.style.display = 'block';
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
for (const [channelId, argData] of Object.entries(data.active_arguments)) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.style.background = '#2a2a3e';
|
||||||
|
div.style.padding = '0.5rem';
|
||||||
|
div.style.marginBottom = '0.5rem';
|
||||||
|
div.style.borderRadius = '4px';
|
||||||
|
div.innerHTML = `
|
||||||
|
<strong>#${argData.channel_name}</strong><br>
|
||||||
|
<small>Exchanges: ${argData.exchange_count} | Speaker: ${argData.current_speaker}</small>
|
||||||
|
`;
|
||||||
|
list.appendChild(div);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
container.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load active arguments:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
1127
bot/static/js/profile.js
Normal file
1127
bot/static/js/profile.js
Normal file
File diff suppressed because it is too large
Load Diff
684
bot/static/js/servers.js
Normal file
684
bot/static/js/servers.js
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
// ===== Server Management Functions =====
|
||||||
|
|
||||||
|
async function loadServers() {
|
||||||
|
try {
|
||||||
|
console.log('🎭 Loading servers...');
|
||||||
|
const data = await apiCall('/servers');
|
||||||
|
console.log('🎭 Servers response:', data);
|
||||||
|
|
||||||
|
if (data.servers) {
|
||||||
|
servers = data.servers;
|
||||||
|
console.log(`🎭 Loaded ${servers.length} servers:`, servers);
|
||||||
|
|
||||||
|
// Debug: Log each server's guild_id
|
||||||
|
servers.forEach((server, index) => {
|
||||||
|
console.log(`🎭 Server ${index}: guild_id = ${server.guild_id}, name = ${server.guild_name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug: Show raw response data
|
||||||
|
console.log('🎭 Raw API response data:', JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
// Display servers
|
||||||
|
displayServers();
|
||||||
|
populateServerDropdowns();
|
||||||
|
populateMoodDropdowns(); // Populate mood dropdowns after servers are loaded
|
||||||
|
} else {
|
||||||
|
console.warn('🎭 No servers found in response');
|
||||||
|
servers = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('🎭 Failed to load servers:', error);
|
||||||
|
servers = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayServers() {
|
||||||
|
const container = document.getElementById('servers-list');
|
||||||
|
|
||||||
|
if (servers.length === 0) {
|
||||||
|
container.innerHTML = '<p>No servers configured</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = servers.map(server => `
|
||||||
|
<div class="server-card">
|
||||||
|
<div class="server-header">
|
||||||
|
<div class="server-name">${server.guild_name}</div>
|
||||||
|
<div class="server-actions">
|
||||||
|
<button onclick="editServer('${String(server.guild_id)}')">Edit</button>
|
||||||
|
<button onclick="removeServer('${String(server.guild_id)}')" style="background: #d32f2f;">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><strong>Guild ID:</strong> ${server.guild_id}</div>
|
||||||
|
<div><strong>Autonomous Channel:</strong> #${server.autonomous_channel_name} (${server.autonomous_channel_id})</div>
|
||||||
|
<div><strong>Bedtime Channels:</strong> ${server.bedtime_channel_ids.join(', ')}</div>
|
||||||
|
<div><strong>Features:</strong>
|
||||||
|
${server.enabled_features.map(feature => `<span class="feature-tag">${feature}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div><strong>Autonomous Interval:</strong> ${server.autonomous_interval_minutes} minutes</div>
|
||||||
|
<div><strong>Conversation Detection:</strong> ${server.conversation_detection_interval_minutes} minutes</div>
|
||||||
|
<div><strong>Bedtime Range:</strong> ${String(server.bedtime_hour || 21).padStart(2, '0')}:${String(server.bedtime_minute || 0).padStart(2, '0')} - ${String(server.bedtime_hour_end || 23).padStart(2, '0')}:${String(server.bedtime_minute_end || 59).padStart(2, '0')}</div>
|
||||||
|
|
||||||
|
<!-- Bedtime Configuration -->
|
||||||
|
<div style="margin-top: 1rem; padding: 1rem; background: #2a2a2a; border-radius: 4px;">
|
||||||
|
<h4 style="margin: 0 0 0.5rem 0; color: #61dafb;">Bedtime Settings</h4>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.9rem; margin-bottom: 0.2rem;">Start Time:</label>
|
||||||
|
<input type="time" id="bedtime-start-${String(server.guild_id)}" value="${String(server.bedtime_hour || 21).padStart(2, '0')}:${String(server.bedtime_minute || 0).padStart(2, '0')}" style="padding: 0.3rem; background: #333; color: white; border: 1px solid #555; border-radius: 3px; width: 100%;">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style="display: block; font-size: 0.9rem; margin-bottom: 0.2rem;">End Time:</label>
|
||||||
|
<input type="time" id="bedtime-end-${String(server.guild_id)}" value="${String(server.bedtime_hour_end || 23).padStart(2, '0')}:${String(server.bedtime_minute_end || 59).padStart(2, '0')}" style="padding: 0.3rem; background: #333; color: white; border: 1px solid #555; border-radius: 3px; width: 100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="updateBedtimeRange('${String(server.guild_id)}')" style="background: #4caf50;">Update Bedtime Range</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-Server Mood Display -->
|
||||||
|
<div style="margin-top: 1rem; padding: 1rem; background: #2a2a2a; border-radius: 4px;">
|
||||||
|
<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>Sleeping:</strong> ${server.is_sleeping ? 'Yes' : 'No'}</div>
|
||||||
|
<div 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;">
|
||||||
|
<option value="">Select Mood...</option>
|
||||||
|
</select>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Debug: Log what element IDs were created
|
||||||
|
console.log('🎭 Server cards rendered. Checking for mood-select elements:');
|
||||||
|
document.querySelectorAll('[id^="mood-select-"]').forEach(el => {
|
||||||
|
console.log(`🎭 Found mood-select element: ${el.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate mood dropdowns after server cards are created
|
||||||
|
populateMoodDropdowns();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateServerDropdowns() {
|
||||||
|
const serverSelect = document.getElementById('server-select');
|
||||||
|
const manualServerSelect = document.getElementById('manual-server-select');
|
||||||
|
const customPromptServerSelect = document.getElementById('custom-prompt-server-select');
|
||||||
|
|
||||||
|
// Clear existing options except "All Servers"
|
||||||
|
serverSelect.innerHTML = '<option value="all">All Servers</option>';
|
||||||
|
manualServerSelect.innerHTML = '<option value="all">All Servers</option>';
|
||||||
|
customPromptServerSelect.innerHTML = '<option value="all">All Servers</option>';
|
||||||
|
|
||||||
|
console.log('🎭 Populating server dropdowns with', servers.length, 'servers');
|
||||||
|
|
||||||
|
// Add server options
|
||||||
|
servers.forEach(server => {
|
||||||
|
console.log(`🎭 Adding server to dropdown: ${server.guild_name} (guild_id: ${server.guild_id}, type: ${typeof server.guild_id})`);
|
||||||
|
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = server.guild_id;
|
||||||
|
option.textContent = server.guild_name;
|
||||||
|
|
||||||
|
serverSelect.appendChild(option.cloneNode(true));
|
||||||
|
manualServerSelect.appendChild(option);
|
||||||
|
customPromptServerSelect.appendChild(option.cloneNode(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug: Check what's actually in the manual-server-select dropdown
|
||||||
|
console.log('🎭 manual-server-select options:');
|
||||||
|
Array.from(manualServerSelect.options).forEach((opt, idx) => {
|
||||||
|
console.log(` [${idx}] value="${opt.value}" text="${opt.textContent}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate autonomous stats dropdown
|
||||||
|
populateAutonomousServerDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Figurine subscribers UI functions (must be global for onclick handlers)
|
||||||
|
async function refreshFigurineSubscribers() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Figurines: Fetching subscribers...');
|
||||||
|
const data = await apiCall('/figurines/subscribers');
|
||||||
|
console.log('📋 Figurines: Received subscribers:', data);
|
||||||
|
displayFigurineSubscribers(data.subscribers || []);
|
||||||
|
showNotification('Subscribers refreshed');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Figurines: Failed to fetch subscribers:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayFigurineSubscribers(subscribers) {
|
||||||
|
const container = document.getElementById('figurine-subscribers-list');
|
||||||
|
if (!container) return;
|
||||||
|
if (!subscribers.length) {
|
||||||
|
container.innerHTML = '<p>No subscribers yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '<ul>';
|
||||||
|
subscribers.forEach(uid => {
|
||||||
|
const uidStr = String(uid);
|
||||||
|
html += `<li><code>${uidStr}</code> <button onclick="removeFigurineSubscriber('${uidStr}')">Remove</button></li>`;
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFigurineSubscriber() {
|
||||||
|
try {
|
||||||
|
console.log('➕ Figurines: Adding subscriber...');
|
||||||
|
const uid = document.getElementById('figurine-user-id').value.trim();
|
||||||
|
if (!uid) {
|
||||||
|
showNotification('Enter a user ID', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('user_id', uid);
|
||||||
|
const res = await fetch('/figurines/subscribers', { method: 'POST', body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('➕ Figurines: Add subscriber response:', data);
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
showNotification('Subscriber added');
|
||||||
|
document.getElementById('figurine-user-id').value = '';
|
||||||
|
refreshFigurineSubscribers();
|
||||||
|
} else {
|
||||||
|
showNotification(data.message || 'Failed to add subscriber', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Figurines: Failed to add subscriber:', e);
|
||||||
|
showNotification('Failed to add subscriber', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFigurineSubscriber(uid) {
|
||||||
|
try {
|
||||||
|
console.log(`🗑️ Figurines: Removing subscriber ${uid}...`);
|
||||||
|
const data = await apiCall(`/figurines/subscribers/${uid}`, 'DELETE');
|
||||||
|
console.log('🗑️ Figurines: Remove subscriber response:', data);
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
showNotification('Subscriber removed');
|
||||||
|
refreshFigurineSubscribers();
|
||||||
|
} else {
|
||||||
|
showNotification(data.message || 'Failed to remove subscriber', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Figurines: Failed to remove subscriber:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFigurineNowToAll() {
|
||||||
|
try {
|
||||||
|
console.log('📨 Figurines: Triggering send to all subscribers...');
|
||||||
|
const tweetUrl = document.getElementById('figurine-tweet-url-all').value.trim();
|
||||||
|
const statusDiv = document.getElementById('figurine-all-status');
|
||||||
|
|
||||||
|
statusDiv.textContent = 'Sending...';
|
||||||
|
statusDiv.style.color = evilMode ? '#ff4444' : '#007bff';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
if (tweetUrl) {
|
||||||
|
formData.append('tweet_url', tweetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/figurines/send_now', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
console.log('📨 Figurines: Send to all response:', data);
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
showNotification('Figurine DMs queued for all subscribers');
|
||||||
|
statusDiv.textContent = 'Queued successfully';
|
||||||
|
statusDiv.style.color = '#28a745';
|
||||||
|
document.getElementById('figurine-tweet-url-all').value = ''; // Clear input
|
||||||
|
} else {
|
||||||
|
showNotification(data.message || 'Bot not ready', 'error');
|
||||||
|
statusDiv.textContent = 'Failed: ' + (data.message || 'Unknown error');
|
||||||
|
statusDiv.style.color = '#dc3545';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Figurines: Failed to queue figurine DMs for all:', e);
|
||||||
|
showNotification('Failed to queue figurine DMs', 'error');
|
||||||
|
document.getElementById('figurine-all-status').textContent = 'Error: ' + e.message;
|
||||||
|
document.getElementById('figurine-all-status').style.color = '#dc3545';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFigurineToSingleUser() {
|
||||||
|
try {
|
||||||
|
const userId = document.getElementById('figurine-single-user-id').value.trim();
|
||||||
|
const tweetUrl = document.getElementById('figurine-tweet-url-single').value.trim();
|
||||||
|
const statusDiv = document.getElementById('figurine-single-status');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
showNotification('Enter a user ID', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📨 Figurines: Sending to single user ${userId}, tweet: ${tweetUrl || 'random'}`);
|
||||||
|
|
||||||
|
statusDiv.textContent = 'Sending...';
|
||||||
|
statusDiv.style.color = evilMode ? '#ff4444' : '#007bff';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('user_id', userId);
|
||||||
|
if (tweetUrl) {
|
||||||
|
formData.append('tweet_url', tweetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/figurines/send_to_user', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
console.log('📨 Figurines: Send to single user response:', data);
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
showNotification(`Figurine DM queued for user ${userId}`);
|
||||||
|
statusDiv.textContent = 'Queued successfully';
|
||||||
|
statusDiv.style.color = '#28a745';
|
||||||
|
document.getElementById('figurine-single-user-id').value = ''; // Clear inputs
|
||||||
|
document.getElementById('figurine-tweet-url-single').value = '';
|
||||||
|
} else {
|
||||||
|
showNotification(data.message || 'Failed to queue DM', 'error');
|
||||||
|
statusDiv.textContent = 'Failed: ' + (data.message || 'Unknown error');
|
||||||
|
statusDiv.style.color = '#dc3545';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Figurines: Failed to queue figurine DM for single user:', e);
|
||||||
|
showNotification('Failed to queue figurine DM', 'error');
|
||||||
|
document.getElementById('figurine-single-status').textContent = 'Error: ' + e.message;
|
||||||
|
document.getElementById('figurine-single-status').style.color = '#dc3545';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the old function for backward compatibility
|
||||||
|
async function sendFigurineNow() {
|
||||||
|
return sendFigurineNowToAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addServer() {
|
||||||
|
// Don't use parseInt() for Discord IDs - they're too large for JS integers
|
||||||
|
const guildId = document.getElementById('new-guild-id').value.trim();
|
||||||
|
const guildName = document.getElementById('new-guild-name').value;
|
||||||
|
const autonomousChannelId = document.getElementById('new-autonomous-channel-id').value.trim();
|
||||||
|
const autonomousChannelName = document.getElementById('new-autonomous-channel-name').value;
|
||||||
|
const bedtimeChannelIds = document.getElementById('new-bedtime-channel-ids').value
|
||||||
|
.split(',').map(id => id.trim()).filter(id => id.length > 0);
|
||||||
|
|
||||||
|
const enabledFeatures = [];
|
||||||
|
if (document.getElementById('feature-autonomous').checked) enabledFeatures.push('autonomous');
|
||||||
|
if (document.getElementById('feature-bedtime').checked) enabledFeatures.push('bedtime');
|
||||||
|
if (document.getElementById('feature-monday-video').checked) enabledFeatures.push('monday_video');
|
||||||
|
|
||||||
|
if (!guildId || !guildName || !autonomousChannelId || !autonomousChannelName) {
|
||||||
|
showNotification('Please fill in all required fields', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall('/servers', 'POST', {
|
||||||
|
guild_id: guildId,
|
||||||
|
guild_name: guildName,
|
||||||
|
autonomous_channel_id: autonomousChannelId,
|
||||||
|
autonomous_channel_name: autonomousChannelName,
|
||||||
|
bedtime_channel_ids: bedtimeChannelIds.length > 0 ? bedtimeChannelIds : [autonomousChannelId],
|
||||||
|
enabled_features: enabledFeatures
|
||||||
|
});
|
||||||
|
|
||||||
|
showNotification('Server added successfully');
|
||||||
|
loadServers();
|
||||||
|
|
||||||
|
// Clear form
|
||||||
|
document.getElementById('new-guild-id').value = '';
|
||||||
|
document.getElementById('new-guild-name').value = '';
|
||||||
|
document.getElementById('new-autonomous-channel-id').value = '';
|
||||||
|
document.getElementById('new-autonomous-channel-name').value = '';
|
||||||
|
document.getElementById('new-bedtime-channel-ids').value = '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add server:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeServer(guildId) {
|
||||||
|
if (!confirm('Are you sure you want to remove this server?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiCall(`/servers/${guildId}`, 'DELETE');
|
||||||
|
showNotification('Server removed successfully');
|
||||||
|
loadServers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove server:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editServer(guildId) {
|
||||||
|
// For now, just show a notification - you can implement a full edit form later
|
||||||
|
showNotification('Edit functionality coming soon!');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repairConfig() {
|
||||||
|
if (!confirm('This will attempt to repair corrupted server configurations. Are you sure?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiCall('/servers/repair', 'POST');
|
||||||
|
showNotification('Configuration repair initiated. Please refresh the page to see updated server list.');
|
||||||
|
loadServers(); // Reload servers to reflect potential changes
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to repair config:', error);
|
||||||
|
showNotification(error.message || 'Failed to repair configuration', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate mood dropdowns with available moods
|
||||||
|
async function populateMoodDropdowns() {
|
||||||
|
try {
|
||||||
|
console.log('🎭 Loading available moods...');
|
||||||
|
const data = await apiCall('/moods/available');
|
||||||
|
console.log('🎭 Available moods response:', data);
|
||||||
|
|
||||||
|
if (data.moods) {
|
||||||
|
console.log(`🎭 Found ${data.moods.length} moods:`, data.moods);
|
||||||
|
const emojiMap = evilMode ? EVIL_MOOD_EMOJIS : MOOD_EMOJIS;
|
||||||
|
|
||||||
|
// Populate the DM mood dropdown (#mood on tab1)
|
||||||
|
const dmMoodSelect = document.getElementById('mood');
|
||||||
|
if (dmMoodSelect) {
|
||||||
|
dmMoodSelect.innerHTML = '';
|
||||||
|
data.moods.forEach(mood => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = mood;
|
||||||
|
opt.textContent = `${emojiMap[mood] || ''} ${mood}`.trim();
|
||||||
|
if (mood === 'neutral') opt.selected = true;
|
||||||
|
dmMoodSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate the chat mood dropdown (#chat-mood-select on tab7)
|
||||||
|
const chatMoodSelect = document.getElementById('chat-mood-select');
|
||||||
|
if (chatMoodSelect) {
|
||||||
|
chatMoodSelect.innerHTML = '';
|
||||||
|
data.moods.forEach(mood => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = mood;
|
||||||
|
opt.textContent = `${emojiMap[mood] || ''} ${mood}`.trim();
|
||||||
|
if (mood === 'neutral') opt.selected = true;
|
||||||
|
chatMoodSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate per-server mood dropdowns (mood-select-{guildId})
|
||||||
|
document.querySelectorAll('[id^="mood-select-"]').forEach(select => {
|
||||||
|
// 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');
|
||||||
|
} else {
|
||||||
|
console.warn('🎭 No moods found in response');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('🎭 Failed to load available moods:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-Server Mood Management
|
||||||
|
async function setServerMood(guildId) {
|
||||||
|
console.log(`🎭 setServerMood called with guildId: ${guildId} (type: ${typeof guildId})`);
|
||||||
|
|
||||||
|
// Ensure guildId is a string for consistency
|
||||||
|
const guildIdStr = String(guildId);
|
||||||
|
console.log(`🎭 Using guildId as string: ${guildIdStr}`);
|
||||||
|
|
||||||
|
// Debug: Check what elements exist
|
||||||
|
const elementId = `mood-select-${guildIdStr}`;
|
||||||
|
console.log(`🎭 Looking for element with ID: ${elementId}`);
|
||||||
|
|
||||||
|
const moodSelect = document.getElementById(elementId);
|
||||||
|
console.log(`🎭 Found element:`, moodSelect);
|
||||||
|
|
||||||
|
if (!moodSelect) {
|
||||||
|
console.error(`🎭 ERROR: Element with ID '${elementId}' not found!`);
|
||||||
|
console.log(`🎭 Available mood-select elements:`, document.querySelectorAll('[id^="mood-select-"]'));
|
||||||
|
showNotification(`Error: Mood selector not found for server ${guildIdStr}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedMood = moodSelect.value;
|
||||||
|
|
||||||
|
console.log(`🎭 Setting mood for server ${guildIdStr} to ${selectedMood}`);
|
||||||
|
|
||||||
|
if (!selectedMood) {
|
||||||
|
showNotification('Please select a mood', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the button and store original text before any changes
|
||||||
|
const button = moodSelect.nextElementSibling;
|
||||||
|
const originalText = button.textContent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
button.textContent = 'Changing...';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
console.log(`🎭 Making API call to /servers/${guildIdStr}/mood with mood: ${selectedMood}`);
|
||||||
|
const response = await apiCall(`/servers/${guildIdStr}/mood`, 'POST', { mood: selectedMood });
|
||||||
|
console.log(`🎭 API response:`, response);
|
||||||
|
|
||||||
|
if (response.status === 'ok') {
|
||||||
|
showNotification(`Server mood changed to ${selectedMood} ${MOOD_EMOJIS[selectedMood] || ''}`);
|
||||||
|
|
||||||
|
// Reset dropdown selection
|
||||||
|
moodSelect.value = '';
|
||||||
|
|
||||||
|
// Reload servers to show updated mood
|
||||||
|
loadServers();
|
||||||
|
} else {
|
||||||
|
showNotification(`Failed to change mood: ${response.message}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`🎭 Error setting mood:`, error);
|
||||||
|
showNotification(`Failed to change mood: ${error}`, 'error');
|
||||||
|
} finally {
|
||||||
|
// Restore button state
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetServerMood(guildId) {
|
||||||
|
console.log(`🎭 resetServerMood called with guildId: ${guildId} (type: ${typeof guildId})`);
|
||||||
|
|
||||||
|
// Ensure guildId is a string for consistency
|
||||||
|
const guildIdStr = String(guildId);
|
||||||
|
console.log(`🎭 Using guildId as string: ${guildIdStr}`);
|
||||||
|
|
||||||
|
const button = document.querySelector(`button[onclick="resetServerMood('${guildIdStr}')"]`);
|
||||||
|
const originalText = button ? button.textContent : 'Reset';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
if (button) {
|
||||||
|
button.textContent = 'Resetting...';
|
||||||
|
button.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiCall(`/servers/${guildIdStr}/mood/reset`, 'POST');
|
||||||
|
showNotification(`Server mood reset to neutral`);
|
||||||
|
|
||||||
|
// Reload servers to show updated mood
|
||||||
|
loadServers();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(`Failed to reset mood: ${error}`, 'error');
|
||||||
|
} finally {
|
||||||
|
// Restore button state
|
||||||
|
if (button) {
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateBedtimeRange(guildId) {
|
||||||
|
console.log(`⏰ updateBedtimeRange called with guildId: ${guildId}`);
|
||||||
|
|
||||||
|
// Ensure guildId is a string for consistency
|
||||||
|
const guildIdStr = String(guildId);
|
||||||
|
|
||||||
|
// Get the time values from the inputs
|
||||||
|
const startTimeInput = document.getElementById(`bedtime-start-${guildIdStr}`);
|
||||||
|
const endTimeInput = document.getElementById(`bedtime-end-${guildIdStr}`);
|
||||||
|
|
||||||
|
if (!startTimeInput || !endTimeInput) {
|
||||||
|
showNotification('Could not find bedtime time inputs', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = startTimeInput.value; // Format: "HH:MM"
|
||||||
|
const endTime = endTimeInput.value; // Format: "HH:MM"
|
||||||
|
|
||||||
|
if (!startTime || !endTime) {
|
||||||
|
showNotification('Please enter both start and end times', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the times
|
||||||
|
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||||
|
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
const button = document.querySelector(`button[onclick="updateBedtimeRange('${guildIdStr}')"]`);
|
||||||
|
const originalText = button ? button.textContent : 'Update Bedtime Range';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
if (button) {
|
||||||
|
button.textContent = 'Updating...';
|
||||||
|
button.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the update request
|
||||||
|
await apiCall(`/servers/${guildIdStr}/bedtime-range`, 'POST', {
|
||||||
|
bedtime_hour: startHour,
|
||||||
|
bedtime_minute: startMinute,
|
||||||
|
bedtime_hour_end: endHour,
|
||||||
|
bedtime_minute_end: endMinute
|
||||||
|
});
|
||||||
|
|
||||||
|
showNotification(`Bedtime range updated: ${startTime} - ${endTime}`);
|
||||||
|
|
||||||
|
// Reload servers to show updated configuration
|
||||||
|
loadServers();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update bedtime range:', error);
|
||||||
|
} finally {
|
||||||
|
// Restore button state
|
||||||
|
if (button) {
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mood Management
|
||||||
|
async function setMood() {
|
||||||
|
const mood = document.getElementById('mood').value;
|
||||||
|
try {
|
||||||
|
// Use different endpoint for evil mode
|
||||||
|
const endpoint = evilMode ? '/evil-mode/mood' : '/mood';
|
||||||
|
await apiCall(endpoint, 'POST', { mood: mood });
|
||||||
|
showNotification(`Mood set to ${mood}`);
|
||||||
|
currentMood = mood;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set mood:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetMood() {
|
||||||
|
try {
|
||||||
|
if (evilMode) {
|
||||||
|
await apiCall('/evil-mode/mood', 'POST', { mood: 'evil_neutral' });
|
||||||
|
showNotification('Evil mood reset to evil_neutral');
|
||||||
|
currentMood = 'evil_neutral';
|
||||||
|
document.getElementById('mood').value = 'evil_neutral';
|
||||||
|
} else {
|
||||||
|
await apiCall('/mood/reset', 'POST');
|
||||||
|
showNotification('Mood reset to neutral');
|
||||||
|
currentMood = 'neutral';
|
||||||
|
document.getElementById('mood').value = 'neutral';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset mood:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calmMiku() {
|
||||||
|
try {
|
||||||
|
if (evilMode) {
|
||||||
|
await apiCall('/evil-mode/mood', 'POST', { mood: 'evil_neutral' });
|
||||||
|
showNotification('Evil Miku has been calmed down');
|
||||||
|
currentMood = 'evil_neutral';
|
||||||
|
document.getElementById('mood').value = 'evil_neutral';
|
||||||
|
} else {
|
||||||
|
await apiCall('/mood/calm', 'POST');
|
||||||
|
showNotification('Miku has been calmed down');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to calm Miku:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Language Mode Functions =====
|
||||||
|
async function refreshLanguageStatus() {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/language');
|
||||||
|
document.getElementById('current-language-display').textContent =
|
||||||
|
result.language_mode === 'japanese' ? '日本語 (Japanese)' : 'English';
|
||||||
|
document.getElementById('status-language').textContent =
|
||||||
|
result.language_mode === 'japanese' ? '日本語 (Japanese)' : 'English';
|
||||||
|
document.getElementById('status-model').textContent = result.current_model;
|
||||||
|
|
||||||
|
console.log('Language status:', result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get language status:', error);
|
||||||
|
showNotification('Failed to load language status', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLanguageMode() {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/language/toggle', 'POST');
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
document.getElementById('current-language-display').textContent =
|
||||||
|
result.language_mode === 'japanese' ? '日本語 (Japanese)' : 'English';
|
||||||
|
document.getElementById('status-language').textContent =
|
||||||
|
result.language_mode === 'japanese' ? '日本語 (Japanese)' : 'English';
|
||||||
|
document.getElementById('status-model').textContent = result.model_now_using;
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
showNotification(result.message, 'success');
|
||||||
|
console.log('Language toggled:', result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to toggle language mode:', error);
|
||||||
|
showNotification('Failed to toggle language mode', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
524
bot/static/js/status.js
Normal file
524
bot/static/js/status.js
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Miku Control Panel — Status Module
|
||||||
|
// Status display, last prompt, autonomous stats
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ===== Status =====
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/status');
|
||||||
|
const statusDiv = document.getElementById('status');
|
||||||
|
|
||||||
|
if (result.evil_mode !== undefined && result.evil_mode !== evilMode) {
|
||||||
|
evilMode = result.evil_mode;
|
||||||
|
updateEvilModeUI();
|
||||||
|
if (evilMode && result.mood) {
|
||||||
|
const moodSelect = document.getElementById('mood');
|
||||||
|
if (moodSelect) moodSelect.value = result.mood;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.mood) {
|
||||||
|
const moodSelect = document.getElementById('mood');
|
||||||
|
if (moodSelect && moodSelect.querySelector(`option[value="${result.mood}"]`)) {
|
||||||
|
moodSelect.value = result.mood;
|
||||||
|
}
|
||||||
|
currentMood = result.mood;
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverMoodsHtml = '';
|
||||||
|
if (result.server_moods) {
|
||||||
|
serverMoodsHtml = '<div style="margin-top: 0.5rem;"><strong>Server Moods:</strong><br>';
|
||||||
|
for (const [guildId, mood] of Object.entries(result.server_moods)) {
|
||||||
|
const server = servers.find(s => s.guild_id == guildId);
|
||||||
|
const serverName = server ? server.guild_name : `Server ${guildId}`;
|
||||||
|
const emojiMap = evilMode ? EVIL_MOOD_EMOJIS : MOOD_EMOJIS;
|
||||||
|
serverMoodsHtml += `• ${serverName}: ${mood} ${emojiMap[mood] || ''}<br>`;
|
||||||
|
}
|
||||||
|
serverMoodsHtml += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const moodEmoji = evilMode ? (EVIL_MOOD_EMOJIS[result.mood] || '') : (MOOD_EMOJIS[result.mood] || '');
|
||||||
|
const moodLabel = evilMode ? `😈 ${result.mood} ${moodEmoji}` : `${result.mood} ${moodEmoji}`;
|
||||||
|
|
||||||
|
statusDiv.innerHTML = `
|
||||||
|
<div><strong>Status:</strong> ${result.status}</div>
|
||||||
|
<div><strong>DM Mood:</strong> ${moodLabel}</div>
|
||||||
|
<div><strong>Servers:</strong> ${result.servers}</div>
|
||||||
|
<div><strong>Active Schedulers:</strong> ${result.active_schedulers}</div>
|
||||||
|
<div style="margin-top: 0.5rem; padding: 0.5rem; background: #2a2a2a; border-radius: 4px; font-size: 0.9rem;">
|
||||||
|
<strong>💬 DM Support:</strong> Users can message Miku directly in DMs. She responds to every DM message using the DM mood (auto-rotating every 2 hours).
|
||||||
|
</div>
|
||||||
|
${serverMoodsHtml}
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Prompt History =====
|
||||||
|
|
||||||
|
let _promptHistoryCache = []; // cached history entries from last fetch
|
||||||
|
let _selectedPromptId = null; // currently selected entry ID
|
||||||
|
let _middleTruncation = false; // whether middle-truncation is active
|
||||||
|
|
||||||
|
async function loadPromptHistory() {
|
||||||
|
const source = localStorage.getItem('miku-prompt-source') || 'all';
|
||||||
|
const selectEl = document.getElementById('prompt-history-select');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = source === 'all' ? '/prompts' : `/prompts?source=${source}`;
|
||||||
|
const result = await apiCall(url);
|
||||||
|
_promptHistoryCache = result.history || [];
|
||||||
|
|
||||||
|
// Populate dropdown
|
||||||
|
const currentValue = selectEl.value;
|
||||||
|
selectEl.innerHTML = '';
|
||||||
|
if (_promptHistoryCache.length === 0) {
|
||||||
|
selectEl.innerHTML = '<option value="">-- No prompts yet --</option>';
|
||||||
|
} else {
|
||||||
|
_promptHistoryCache.forEach(entry => {
|
||||||
|
const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '?';
|
||||||
|
const srcLabel = entry.source === 'cat' ? '🐱' : '🤖';
|
||||||
|
const user = entry.user || '?';
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = entry.id;
|
||||||
|
option.textContent = `${srcLabel} #${entry.id} — ${user} — ${ts}`;
|
||||||
|
selectEl.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore or auto-select the latest entry
|
||||||
|
if (_selectedPromptId && _promptHistoryCache.some(e => e.id === _selectedPromptId)) {
|
||||||
|
selectEl.value = _selectedPromptId;
|
||||||
|
} else if (_promptHistoryCache.length > 0) {
|
||||||
|
selectEl.value = _promptHistoryCache[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectEl.value) {
|
||||||
|
await selectPromptEntry(selectEl.value);
|
||||||
|
} else {
|
||||||
|
clearPromptDisplay();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load prompt history:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectPromptEntry(promptId) {
|
||||||
|
if (!promptId) {
|
||||||
|
clearPromptDisplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedPromptId = parseInt(promptId);
|
||||||
|
|
||||||
|
// Try cache first
|
||||||
|
let entry = _promptHistoryCache.find(e => e.id === _selectedPromptId);
|
||||||
|
|
||||||
|
// Fall back to API call if not in cache
|
||||||
|
if (!entry) {
|
||||||
|
try {
|
||||||
|
entry = await apiCall(`/prompts/${_selectedPromptId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load prompt entry:', error);
|
||||||
|
clearPromptDisplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
clearPromptDisplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPromptEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPromptDisplay() {
|
||||||
|
document.getElementById('prompt-metadata').innerHTML = '';
|
||||||
|
document.getElementById('prompt-display').innerHTML = '<pre style="white-space: pre-wrap; word-break: break-word; background: #1a1a1a; padding: 0.75rem; border-radius: 4px; font-size: 0.8rem; line-height: 1.4; margin: 0; color: #666;">No prompt selected.</pre>';
|
||||||
|
document.getElementById('last-prompt').textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPromptEntry(entry) {
|
||||||
|
// Metadata bar
|
||||||
|
const metaEl = document.getElementById('prompt-metadata');
|
||||||
|
const ts = entry.timestamp ? new Date(entry.timestamp).toLocaleString() : '?';
|
||||||
|
const sourceIcon = entry.source === 'cat' ? '🐱 Cat' : '🤖 Fallback';
|
||||||
|
metaEl.innerHTML = `
|
||||||
|
<span><span class="prompt-meta-label">#</span><span class="prompt-meta-value">${entry.id}</span></span>
|
||||||
|
<span><span class="prompt-meta-label">Source:</span> <span class="prompt-meta-value">${sourceIcon}</span></span>
|
||||||
|
<span><span class="prompt-meta-label">User:</span> <span class="prompt-meta-value">${escapeHtml(entry.user || '?')}</span></span>
|
||||||
|
<span><span class="prompt-meta-label">Mood:</span> <span class="prompt-meta-value">${escapeHtml(entry.mood || '?')}</span></span>
|
||||||
|
<span><span class="prompt-meta-label">Guild:</span> <span class="prompt-meta-value">${escapeHtml(entry.guild || '?')}</span></span>
|
||||||
|
<span><span class="prompt-meta-label">Channel:</span> <span class="prompt-meta-value">${escapeHtml(entry.channel || '?')}</span></span>
|
||||||
|
<span><span class="prompt-meta-label">Model:</span> <span class="prompt-meta-value">${escapeHtml(entry.model || '?')}</span></span>
|
||||||
|
<span><span class="prompt-meta-label">Type:</span> <span class="prompt-meta-value">${escapeHtml(entry.response_type || '?')}</span></span>
|
||||||
|
<span><span class="prompt-meta-label">Time:</span> <span class="prompt-meta-value">${ts}</span></span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Parse full_prompt into sections
|
||||||
|
const sections = parsePromptSections(entry.full_prompt || '');
|
||||||
|
|
||||||
|
// Snapshot which subsections are currently collapsed (before re-render)
|
||||||
|
const sectionIds = ['system', 'context', 'conversation', 'response'];
|
||||||
|
const collapsedState = {};
|
||||||
|
sectionIds.forEach(id => {
|
||||||
|
const el = document.getElementById(`prompt-section-${id}`);
|
||||||
|
collapsedState[id] = el && el.classList.contains('collapsed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build display HTML with collapsible subsections
|
||||||
|
let displayHtml = '';
|
||||||
|
|
||||||
|
if (sections.system) {
|
||||||
|
displayHtml += buildCollapsibleSection('System Prompt', sections.system, 'system');
|
||||||
|
}
|
||||||
|
if (sections.context) {
|
||||||
|
displayHtml += buildCollapsibleSection('Context (Memories & Tools)', sections.context, 'context');
|
||||||
|
}
|
||||||
|
if (sections.conversation) {
|
||||||
|
displayHtml += buildCollapsibleSection('Conversation', sections.conversation, 'conversation');
|
||||||
|
}
|
||||||
|
if (!sections.system && !sections.context && !sections.conversation) {
|
||||||
|
// Fallback: show raw full_prompt
|
||||||
|
displayHtml += `<pre style="white-space: pre-wrap; word-break: break-word; margin: 0;">${escapeHtml(entry.full_prompt || '')}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response section
|
||||||
|
if (entry.response) {
|
||||||
|
let responseText = entry.response;
|
||||||
|
if (_middleTruncation && responseText.length > 400) {
|
||||||
|
responseText = responseText.substring(0, 200) + '\n\n... [truncated middle] ...\n\n' + responseText.substring(responseText.length - 200);
|
||||||
|
}
|
||||||
|
displayHtml += buildCollapsibleSection('Response', responseText, 'response');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render into the prompt-display div (using innerHTML for collapsible structure)
|
||||||
|
const displayEl = document.getElementById('prompt-display');
|
||||||
|
displayEl.innerHTML = displayHtml;
|
||||||
|
|
||||||
|
// Restore collapsed state from snapshot
|
||||||
|
sectionIds.forEach(id => {
|
||||||
|
const el = document.getElementById(`prompt-section-${id}`);
|
||||||
|
if (el && collapsedState[id]) {
|
||||||
|
el.classList.add('collapsed');
|
||||||
|
const header = el.previousElementSibling;
|
||||||
|
if (header) header.innerHTML = header.innerHTML.replace('▼', '▶');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also set the raw text into the <pre> for copy functionality
|
||||||
|
let rawText = entry.full_prompt || '';
|
||||||
|
if (entry.response) {
|
||||||
|
rawText += `\n\n${'═'.repeat(60)}\n[Response]\n${entry.response}`;
|
||||||
|
}
|
||||||
|
document.getElementById('last-prompt').textContent = rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePromptSections(fullPrompt) {
|
||||||
|
const sections = { system: null, context: null, conversation: null };
|
||||||
|
|
||||||
|
if (!fullPrompt) return sections;
|
||||||
|
|
||||||
|
// Try to split on known section markers
|
||||||
|
const contextMatch = fullPrompt.match(/# Context\s*\n([\s\S]*?)(?=\n# Conversation|\nHuman:|\n$)/);
|
||||||
|
const convMatch = fullPrompt.match(/# Conversation until now:\s*\n([\s\S]*)/);
|
||||||
|
|
||||||
|
if (contextMatch) {
|
||||||
|
// Everything before # Context is the system prompt
|
||||||
|
const contextIdx = fullPrompt.indexOf('# Context');
|
||||||
|
if (contextIdx > 0) {
|
||||||
|
sections.system = fullPrompt.substring(0, contextIdx).trim();
|
||||||
|
}
|
||||||
|
sections.context = contextMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convMatch) {
|
||||||
|
sections.conversation = convMatch[1].trim();
|
||||||
|
} else {
|
||||||
|
// Try alternative: "Human:" at the end
|
||||||
|
const humanMatch = fullPrompt.match(/\nHuman:([\s\S]*)/);
|
||||||
|
if (humanMatch && fullPrompt.indexOf('Human:') > fullPrompt.indexOf('# Context')) {
|
||||||
|
sections.conversation = 'Human:' + humanMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no # Context marker, try "System:" prefix (fallback prompts)
|
||||||
|
if (!sections.system && !sections.context) {
|
||||||
|
const sysMatch = fullPrompt.match(/^System:\s*([\s\S]*?)(?=\nMessages:)/);
|
||||||
|
const msgMatch = fullPrompt.match(/Messages:\s*([\s\S]*)/);
|
||||||
|
if (sysMatch) {
|
||||||
|
sections.system = sysMatch[1].trim();
|
||||||
|
}
|
||||||
|
if (msgMatch) {
|
||||||
|
sections.conversation = msgMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCollapsibleSection(title, content, sectionId) {
|
||||||
|
const id = `prompt-section-${sectionId}`;
|
||||||
|
return `
|
||||||
|
<div class="prompt-subsection-header" onclick="togglePromptSubsection('${id}')">
|
||||||
|
▼ ${escapeHtml(title)}
|
||||||
|
</div>
|
||||||
|
<div class="prompt-subsection-body" id="${id}">
|
||||||
|
<pre style="white-space: pre-wrap; word-break: break-word; background: #1a1a1a; padding: 0.5rem; border-radius: 4px; font-size: 0.8rem; line-height: 1.4; margin: 0.25rem 0;">${escapeHtml(content)}</pre>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePromptSubsection(id) {
|
||||||
|
const body = document.getElementById(id);
|
||||||
|
if (!body) return;
|
||||||
|
const header = body.previousElementSibling;
|
||||||
|
if (body.classList.contains('collapsed')) {
|
||||||
|
body.classList.remove('collapsed');
|
||||||
|
if (header) header.innerHTML = header.innerHTML.replace('▶', '▼');
|
||||||
|
} else {
|
||||||
|
body.classList.add('collapsed');
|
||||||
|
if (header) header.innerHTML = header.innerHTML.replace('▼', '▶');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePromptHistoryCollapse() {
|
||||||
|
const section = document.getElementById('prompt-history-section');
|
||||||
|
const toggle = document.getElementById('prompt-history-toggle');
|
||||||
|
if (section.classList.contains('collapsed')) {
|
||||||
|
section.classList.remove('collapsed');
|
||||||
|
toggle.textContent = '▼ Prompt History';
|
||||||
|
} else {
|
||||||
|
section.classList.add('collapsed');
|
||||||
|
toggle.textContent = '▶ Prompt History';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPromptToClipboard() {
|
||||||
|
const rawText = document.getElementById('last-prompt').textContent;
|
||||||
|
if (!rawText) return;
|
||||||
|
navigator.clipboard.writeText(rawText).then(() => {
|
||||||
|
showNotification('Prompt copied to clipboard', 'success');
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
showNotification('Failed to copy', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMiddleTruncation() {
|
||||||
|
_middleTruncation = document.getElementById('prompt-truncate-toggle').checked;
|
||||||
|
// Re-render current entry
|
||||||
|
if (_selectedPromptId) {
|
||||||
|
selectPromptEntry(_selectedPromptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy compatibility — called from core.js on page load / tab switch
|
||||||
|
// Redirects to the new loadPromptHistory()
|
||||||
|
async function loadLastPrompt() {
|
||||||
|
await loadPromptHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Autonomous Stats =====
|
||||||
|
|
||||||
|
async function loadAutonomousStats() {
|
||||||
|
const serverSelect = document.getElementById('autonomous-server-select');
|
||||||
|
const selectedGuildId = serverSelect.value;
|
||||||
|
|
||||||
|
if (!selectedGuildId) {
|
||||||
|
document.getElementById('autonomous-stats-display').innerHTML = '<p style="color: #aaa;">Please select a server to view autonomous stats.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiCall('/autonomous/stats');
|
||||||
|
|
||||||
|
if (!data.servers || !data.servers[selectedGuildId]) {
|
||||||
|
document.getElementById('autonomous-stats-display').innerHTML = '<p style="color: #ff5555;">Server not found or not initialized.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverData = data.servers[selectedGuildId];
|
||||||
|
displayAutonomousStats(serverData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load autonomous stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayAutonomousStats(data) {
|
||||||
|
const container = document.getElementById('autonomous-stats-display');
|
||||||
|
|
||||||
|
if (!data.context) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px;">
|
||||||
|
<h4 style="color: #61dafb; margin-top: 0;">⚠️ Context Not Initialized</h4>
|
||||||
|
<p>This server hasn't had any activity yet. Context tracking will begin once messages are sent.</p>
|
||||||
|
<div style="margin-top: 1rem; padding: 1rem; background: #1e1e1e; border-radius: 4px;">
|
||||||
|
<strong>Current Mood:</strong> ${data.mood} ${MOOD_EMOJIS[data.mood] || ''}<br>
|
||||||
|
<strong>Energy:</strong> ${data.mood_profile.energy}<br>
|
||||||
|
<strong>Sociability:</strong> ${data.mood_profile.sociability}<br>
|
||||||
|
<strong>Impulsiveness:</strong> ${data.mood_profile.impulsiveness}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = data.context;
|
||||||
|
const profile = data.mood_profile;
|
||||||
|
|
||||||
|
const lastActionMin = Math.floor(ctx.time_since_last_action / 60);
|
||||||
|
const lastInteractionMin = Math.floor(ctx.time_since_last_interaction / 60);
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
|
<h4 style="color: #61dafb; margin-top: 0;">🎭 Mood & Personality Profile</h4>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.3rem;">Current Mood</div>
|
||||||
|
<div style="font-size: 1.5rem; font-weight: bold;">${data.mood} ${MOOD_EMOJIS[data.mood] || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.3rem;">Energy Level</div>
|
||||||
|
<div style="font-size: 1.5rem; font-weight: bold; color: ${getStatColor(profile.energy)}">${(profile.energy * 100).toFixed(0)}%</div>
|
||||||
|
<div style="width: 100%; height: 6px; background: #333; border-radius: 3px; margin-top: 0.5rem;">
|
||||||
|
<div style="width: ${profile.energy * 100}%; height: 100%; background: ${getStatColor(profile.energy)}; border-radius: 3px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.3rem;">Sociability</div>
|
||||||
|
<div style="font-size: 1.5rem; font-weight: bold; color: ${getStatColor(profile.sociability)}">${(profile.sociability * 100).toFixed(0)}%</div>
|
||||||
|
<div style="width: 100%; height: 6px; background: #333; border-radius: 3px; margin-top: 0.5rem;">
|
||||||
|
<div style="width: ${profile.sociability * 100}%; height: 100%; background: ${getStatColor(profile.sociability)}; border-radius: 3px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.3rem;">Impulsiveness</div>
|
||||||
|
<div style="font-size: 1.5rem; font-weight: bold; color: ${getStatColor(profile.impulsiveness)}">${(profile.impulsiveness * 100).toFixed(0)}%</div>
|
||||||
|
<div style="width: 100%; height: 6px; background: #333; border-radius: 3px; margin-top: 0.5rem;">
|
||||||
|
<div style="width: ${profile.impulsiveness * 100}%; height: 100%; background: ${getStatColor(profile.impulsiveness)}; border-radius: 3px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
|
<h4 style="color: #61dafb; margin-top: 0;">📈 Activity Metrics</h4>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Messages (Last 5 min) <span style="color: #666;">⚡ ephemeral</span></div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: #4caf50;">${ctx.messages_last_5min}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Messages (Last Hour) <span style="color: #666;">⚡ ephemeral</span></div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: #2196f3;">${ctx.messages_last_hour}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Conversation Momentum <span style="color: #4caf50;">💾 saved</span></div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: ${getMomentumColor(ctx.conversation_momentum)}">${(ctx.conversation_momentum * 100).toFixed(0)}%</div>
|
||||||
|
<div style="font-size: 0.75rem; color: #888; margin-top: 0.3rem;">Decays with downtime (half-life: 10min)</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Unique Users Active <span style="color: #666;">⚡ ephemeral</span></div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: #ff9800;">${ctx.unique_users_active}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
|
<h4 style="color: #61dafb; margin-top: 0;">👥 User Events</h4>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Users Joined Recently</div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: #4caf50;">${ctx.users_joined_recently}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Status Changes</div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: #2196f3;">${ctx.users_status_changed}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Active Activities</div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: #9c27b0;">${ctx.users_started_activity.length}</div>
|
||||||
|
${ctx.users_started_activity.length > 0 ? `<div style="font-size: 0.8rem; margin-top: 0.5rem; color: #aaa;">${ctx.users_started_activity.join(', ')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
|
||||||
|
<h4 style="color: #61dafb; margin-top: 0;">⏱️ Timing & Context</h4>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Time Since Last Action <span style="color: #4caf50;">💾 saved</span></div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: #ff5722;">${lastActionMin} min</div>
|
||||||
|
<div style="font-size: 0.8rem; color: #888;">${ctx.time_since_last_action.toFixed(1)}s</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Time Since Last Interaction <span style="color: #4caf50;">💾 saved</span></div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: #ff9800;">${lastInteractionMin} min</div>
|
||||||
|
<div style="font-size: 0.8rem; color: #888;">${ctx.time_since_last_interaction.toFixed(1)}s</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Messages Since Last Appearance <span style="color: #4caf50;">💾 saved</span></div>
|
||||||
|
<div style="font-size: 1.8rem; font-weight: bold; color: #2196f3;">${ctx.messages_since_last_appearance}</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa;">Current Time Context <span style="color: #666;">⚡ ephemeral</span></div>
|
||||||
|
<div style="font-size: 1.5rem; font-weight: bold; color: #61dafb;">${ctx.hour_of_day}:00</div>
|
||||||
|
<div style="font-size: 0.8rem; color: #888;">${ctx.is_weekend ? '📅 Weekend' : '📆 Weekday'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #2a2a2a; padding: 1.5rem; border-radius: 8px;">
|
||||||
|
<h4 style="color: #61dafb; margin-top: 0;">🧠 Base Energy Level</h4>
|
||||||
|
<div style="background: #1e1e1e; padding: 1rem; border-radius: 4px;">
|
||||||
|
<div style="font-size: 0.9rem; color: #aaa; margin-bottom: 0.5rem;">From current mood personality</div>
|
||||||
|
<div style="font-size: 2rem; font-weight: bold; color: ${getStatColor(ctx.mood_energy_level)}">${(ctx.mood_energy_level * 100).toFixed(0)}%</div>
|
||||||
|
<div style="width: 100%; height: 10px; background: #333; border-radius: 5px; margin-top: 0.5rem;">
|
||||||
|
<div style="width: ${ctx.mood_energy_level * 100}%; height: 100%; background: ${getStatColor(ctx.mood_energy_level)}; border-radius: 5px;"></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.85rem; color: #888; margin-top: 0.5rem;">
|
||||||
|
💡 Combined with activity metrics to determine action likelihood.<br>
|
||||||
|
📝 High energy = shorter wait times, higher action chance.<br>
|
||||||
|
💾 <strong>Persisted across restarts</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatColor(value) {
|
||||||
|
if (value >= 0.8) return '#4caf50';
|
||||||
|
if (value >= 0.6) return '#8bc34a';
|
||||||
|
if (value >= 0.4) return '#ffc107';
|
||||||
|
if (value >= 0.2) return '#ff9800';
|
||||||
|
return '#f44336';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMomentumColor(value) {
|
||||||
|
if (value >= 0.7) return '#4caf50';
|
||||||
|
if (value >= 0.4) return '#2196f3';
|
||||||
|
return '#9e9e9e';
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateAutonomousServerDropdown() {
|
||||||
|
const select = document.getElementById('autonomous-server-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
const currentValue = select.value;
|
||||||
|
select.innerHTML = '<option value="">-- Select a server --</option>';
|
||||||
|
|
||||||
|
servers.forEach(server => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = server.guild_id;
|
||||||
|
option.textContent = `${server.guild_name} (${server.guild_id})`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentValue && servers.some(s => String(s.guild_id) === currentValue)) {
|
||||||
|
select.value = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
bot/tests/run_tests.sh
Executable file
25
bot/tests/run_tests.sh
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the config/state regression tests inside the miku-bot Docker container.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./bot/tests/run_tests.sh # build + run
|
||||||
|
# ./bot/tests/run_tests.sh --no-build # skip rebuild
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.." # repo root
|
||||||
|
|
||||||
|
if [[ "${1:-}" != "--no-build" ]]; then
|
||||||
|
echo "Building miku-bot image..."
|
||||||
|
docker compose build miku-bot
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Running config/state regression tests..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(pwd)/config.yaml:/config.yaml:ro" \
|
||||||
|
-v "$(pwd)/bot/tests:/app/tests:ro" \
|
||||||
|
-e DISCORD_BOT_TOKEN=test_token \
|
||||||
|
miku-discord-miku-bot \
|
||||||
|
python tests/test_config_state.py
|
||||||
511
bot/tests/test_config_state.py
Normal file
511
bot/tests/test_config_state.py
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
"""
|
||||||
|
Regression test suite for config/state hardening (Steps 1-10).
|
||||||
|
|
||||||
|
Run inside Docker:
|
||||||
|
docker compose run --rm miku-bot python tests/test_config_state.py
|
||||||
|
|
||||||
|
Each test is an independent function. Tests use a temporary directory
|
||||||
|
for all file I/O so they never touch the real config/memory files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Bootstrap ──
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
os.chdir("/app")
|
||||||
|
os.environ.setdefault("DISCORD_BOT_TOKEN", "test_token")
|
||||||
|
|
||||||
|
# ── Imports (after path setup) ──
|
||||||
|
import globals as g
|
||||||
|
from config import CONFIG
|
||||||
|
from config_manager import ConfigManager
|
||||||
|
from server_manager import ServerManager, ServerConfig
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Test Runner
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_results: list[tuple[str, bool, str]] = [] # (name, passed, detail)
|
||||||
|
|
||||||
|
|
||||||
|
def run_test(func):
|
||||||
|
"""Decorator-free runner: call run_test(fn) to execute and record."""
|
||||||
|
name = func.__name__
|
||||||
|
try:
|
||||||
|
func()
|
||||||
|
_results.append((name, True, ""))
|
||||||
|
print(f" ✓ {name}")
|
||||||
|
except AssertionError as e:
|
||||||
|
_results.append((name, False, str(e)))
|
||||||
|
print(f" ✗ {name}: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
_results.append((name, False, f"{type(e).__name__}: {e}"))
|
||||||
|
print(f" ✗ {name}: {type(e).__name__}: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tmp_dir():
|
||||||
|
"""Create a fresh temp directory and return its Path."""
|
||||||
|
d = tempfile.mkdtemp(prefix="miku_test_")
|
||||||
|
return Path(d)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# A. Config Loading & Persistence (Steps 1-2)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def test_runtime_config_persists_in_memory_dir():
|
||||||
|
"""Step 1: config_runtime.yaml lives inside memory/ (volume-mounted)."""
|
||||||
|
from config_manager import config_manager
|
||||||
|
runtime_path = config_manager.runtime_config_path
|
||||||
|
assert "memory" in str(runtime_path), \
|
||||||
|
f"runtime_config_path should be in memory/: {runtime_path}"
|
||||||
|
assert runtime_path.parent.name == "memory", \
|
||||||
|
f"Parent dir should be 'memory', got: {runtime_path.parent.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_yaml_loads_into_pydantic():
|
||||||
|
"""Step 2: config.yaml parses cleanly into AppConfig."""
|
||||||
|
assert CONFIG is not None, "CONFIG is None"
|
||||||
|
assert hasattr(CONFIG, 'discord'), "CONFIG missing 'discord' section"
|
||||||
|
assert hasattr(CONFIG, 'autonomous'), "CONFIG missing 'autonomous' section"
|
||||||
|
assert hasattr(CONFIG, 'voice'), "CONFIG missing 'voice' section"
|
||||||
|
assert hasattr(CONFIG, 'gpu'), "CONFIG missing 'gpu' section"
|
||||||
|
assert hasattr(CONFIG, 'cheshire_cat'), "CONFIG missing 'cheshire_cat' section"
|
||||||
|
assert hasattr(CONFIG, 'services'), "CONFIG missing 'services' section"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_overrides_merge():
|
||||||
|
"""Runtime overrides take precedence over static config."""
|
||||||
|
tmp = _make_tmp_dir()
|
||||||
|
try:
|
||||||
|
# Write a minimal static config
|
||||||
|
static = tmp / "config.yaml"
|
||||||
|
static.write_text("discord:\n language_mode: english\n")
|
||||||
|
|
||||||
|
# Write runtime override
|
||||||
|
mem = tmp / "memory"
|
||||||
|
mem.mkdir()
|
||||||
|
(mem / "config_runtime.yaml").write_text(
|
||||||
|
"discord:\n language_mode: japanese\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cm = ConfigManager(config_path=str(static))
|
||||||
|
# Patch memory_dir to our temp location
|
||||||
|
cm.memory_dir = mem
|
||||||
|
cm.runtime_config_path = mem / "config_runtime.yaml"
|
||||||
|
cm.runtime_config = cm._load_runtime_config()
|
||||||
|
|
||||||
|
val = cm.get("discord.language_mode")
|
||||||
|
assert val == "japanese", f"Expected 'japanese', got {val!r}"
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# B. Runtime State (Steps 4-6)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def test_runtime_state_reads_live_globals():
|
||||||
|
"""Step 4: runtime_state property reads current globals, not stale cache."""
|
||||||
|
from config_manager import config_manager
|
||||||
|
old = g.DM_MOOD
|
||||||
|
try:
|
||||||
|
g.DM_MOOD = "test_sentinel_mood"
|
||||||
|
state = config_manager.runtime_state
|
||||||
|
assert state["dm_mood"] == "test_sentinel_mood", \
|
||||||
|
f"runtime_state returned {state['dm_mood']!r}, expected 'test_sentinel_mood'"
|
||||||
|
finally:
|
||||||
|
g.DM_MOOD = old
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_set_syncs_all_simple_globals():
|
||||||
|
"""Step 6: the _GLOBALS_SYNC map covers all 5 simple settings."""
|
||||||
|
_GLOBALS_SYNC = {
|
||||||
|
"discord.language_mode": ("LANGUAGE_MODE", str),
|
||||||
|
"autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool),
|
||||||
|
"voice.debug_mode": ("VOICE_DEBUG_MODE", bool),
|
||||||
|
"memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool),
|
||||||
|
"gpu.prefer_amd": ("PREFER_AMD_GPU", bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save originals (use getattr with default since some may be dynamically created)
|
||||||
|
originals = {attr: getattr(g, attr, None) for _, (attr, _) in _GLOBALS_SYNC.items()}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set each to a non-default value
|
||||||
|
test_values = {
|
||||||
|
"discord.language_mode": "japanese",
|
||||||
|
"autonomous.debug_mode": True,
|
||||||
|
"voice.debug_mode": True,
|
||||||
|
"memory.use_cheshire_cat": True,
|
||||||
|
"gpu.prefer_amd": True,
|
||||||
|
}
|
||||||
|
for key_path, value in test_values.items():
|
||||||
|
attr, converter = _GLOBALS_SYNC[key_path]
|
||||||
|
setattr(g, attr, converter(value))
|
||||||
|
|
||||||
|
for key_path, value in test_values.items():
|
||||||
|
attr, _ = _GLOBALS_SYNC[key_path]
|
||||||
|
actual = getattr(g, attr)
|
||||||
|
assert actual == value, f"globals.{attr}: expected {value!r}, got {actual!r}"
|
||||||
|
finally:
|
||||||
|
# Restore (delete attr if it didn't exist before)
|
||||||
|
for attr, orig in originals.items():
|
||||||
|
if orig is None and not hasattr(g, attr):
|
||||||
|
continue
|
||||||
|
elif orig is None:
|
||||||
|
delattr(g, attr)
|
||||||
|
else:
|
||||||
|
setattr(g, attr, orig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_set_syncs_dm_mood():
|
||||||
|
"""Step 6: DM mood sync updates both DM_MOOD and DM_MOOD_DESCRIPTION."""
|
||||||
|
old_mood = g.DM_MOOD
|
||||||
|
old_desc = g.DM_MOOD_DESCRIPTION
|
||||||
|
try:
|
||||||
|
if g.AVAILABLE_MOODS:
|
||||||
|
test_mood = g.AVAILABLE_MOODS[0]
|
||||||
|
g.DM_MOOD = test_mood
|
||||||
|
g.DM_MOOD_DESCRIPTION = f"I'm feeling {test_mood} today."
|
||||||
|
assert g.DM_MOOD == test_mood, f"DM_MOOD: expected {test_mood!r}"
|
||||||
|
assert test_mood in g.DM_MOOD_DESCRIPTION, \
|
||||||
|
f"DM_MOOD_DESCRIPTION should contain {test_mood!r}"
|
||||||
|
else:
|
||||||
|
# No available moods to test with; just verify the list exists
|
||||||
|
assert isinstance(g.AVAILABLE_MOODS, list), "AVAILABLE_MOODS should be a list"
|
||||||
|
finally:
|
||||||
|
g.DM_MOOD = old_mood
|
||||||
|
g.DM_MOOD_DESCRIPTION = old_desc
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_runtime_settings():
|
||||||
|
"""Step 4: restore_runtime_settings() pushes persisted values into globals."""
|
||||||
|
tmp = _make_tmp_dir()
|
||||||
|
try:
|
||||||
|
mem = tmp / "memory"
|
||||||
|
mem.mkdir()
|
||||||
|
|
||||||
|
# Write a runtime config with overrides
|
||||||
|
(mem / "config_runtime.yaml").write_text(
|
||||||
|
"discord:\n language_mode: japanese\n"
|
||||||
|
"autonomous:\n debug_mode: true\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
static = tmp / "config.yaml"
|
||||||
|
static.write_text("discord:\n language_mode: english\n")
|
||||||
|
|
||||||
|
cm = ConfigManager(config_path=str(static))
|
||||||
|
cm.memory_dir = mem
|
||||||
|
cm.runtime_config_path = mem / "config_runtime.yaml"
|
||||||
|
cm.runtime_config = cm._load_runtime_config()
|
||||||
|
|
||||||
|
# Save originals
|
||||||
|
old_lang = g.LANGUAGE_MODE
|
||||||
|
old_debug = g.AUTONOMOUS_DEBUG
|
||||||
|
try:
|
||||||
|
g.LANGUAGE_MODE = "english"
|
||||||
|
g.AUTONOMOUS_DEBUG = False
|
||||||
|
|
||||||
|
cm.restore_runtime_settings()
|
||||||
|
|
||||||
|
assert g.LANGUAGE_MODE == "japanese", \
|
||||||
|
f"Expected 'japanese', got {g.LANGUAGE_MODE!r}"
|
||||||
|
assert g.AUTONOMOUS_DEBUG is True, \
|
||||||
|
f"Expected True, got {g.AUTONOMOUS_DEBUG!r}"
|
||||||
|
finally:
|
||||||
|
g.LANGUAGE_MODE = old_lang
|
||||||
|
g.AUTONOMOUS_DEBUG = old_debug
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# C. Reset (Step 5)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def test_reset_to_defaults_resets_all_globals():
|
||||||
|
"""Step 5: full reset restores all globals to CONFIG defaults."""
|
||||||
|
from config_manager import config_manager
|
||||||
|
|
||||||
|
# Save originals (use getattr with default since PREFER_AMD_GPU may be dynamically created)
|
||||||
|
attrs_to_save = ["LANGUAGE_MODE", "AUTONOMOUS_DEBUG", "VOICE_DEBUG_MODE",
|
||||||
|
"USE_CHESHIRE_CAT", "PREFER_AMD_GPU", "DM_MOOD"]
|
||||||
|
saved = {attr: getattr(g, attr, None) for attr in attrs_to_save}
|
||||||
|
try:
|
||||||
|
# Mutate globals away from defaults
|
||||||
|
g.LANGUAGE_MODE = "japanese"
|
||||||
|
g.AUTONOMOUS_DEBUG = True
|
||||||
|
g.VOICE_DEBUG_MODE = True
|
||||||
|
g.USE_CHESHIRE_CAT = True
|
||||||
|
g.PREFER_AMD_GPU = True
|
||||||
|
g.DM_MOOD = "chaotic_test_mood"
|
||||||
|
|
||||||
|
config_manager.reset_to_defaults()
|
||||||
|
|
||||||
|
assert g.LANGUAGE_MODE == CONFIG.discord.language_mode, \
|
||||||
|
f"LANGUAGE_MODE: {g.LANGUAGE_MODE!r}"
|
||||||
|
assert g.AUTONOMOUS_DEBUG == CONFIG.autonomous.debug_mode, \
|
||||||
|
f"AUTONOMOUS_DEBUG: {g.AUTONOMOUS_DEBUG!r}"
|
||||||
|
assert g.VOICE_DEBUG_MODE == CONFIG.voice.debug_mode, \
|
||||||
|
f"VOICE_DEBUG_MODE: {g.VOICE_DEBUG_MODE!r}"
|
||||||
|
assert g.USE_CHESHIRE_CAT == CONFIG.cheshire_cat.enabled, \
|
||||||
|
f"USE_CHESHIRE_CAT: {g.USE_CHESHIRE_CAT!r}"
|
||||||
|
assert getattr(g, 'PREFER_AMD_GPU', CONFIG.gpu.prefer_amd) == CONFIG.gpu.prefer_amd, \
|
||||||
|
f"PREFER_AMD_GPU: {getattr(g, 'PREFER_AMD_GPU', None)!r}"
|
||||||
|
assert g.DM_MOOD == "neutral", f"DM_MOOD: {g.DM_MOOD!r}"
|
||||||
|
finally:
|
||||||
|
for attr, val in saved.items():
|
||||||
|
if val is None and hasattr(g, attr):
|
||||||
|
delattr(g, attr)
|
||||||
|
elif val is not None:
|
||||||
|
setattr(g, attr, val)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_single_key():
|
||||||
|
"""Step 5: single-key reset only affects that one global."""
|
||||||
|
from config_manager import config_manager
|
||||||
|
|
||||||
|
old_lang = g.LANGUAGE_MODE
|
||||||
|
old_debug = g.AUTONOMOUS_DEBUG
|
||||||
|
try:
|
||||||
|
g.LANGUAGE_MODE = "japanese"
|
||||||
|
g.AUTONOMOUS_DEBUG = True
|
||||||
|
|
||||||
|
config_manager.reset_to_defaults("discord.language_mode")
|
||||||
|
|
||||||
|
assert g.LANGUAGE_MODE == CONFIG.discord.language_mode, \
|
||||||
|
f"LANGUAGE_MODE should be default, got {g.LANGUAGE_MODE!r}"
|
||||||
|
# Other globals should NOT have been reset
|
||||||
|
assert g.AUTONOMOUS_DEBUG is True, \
|
||||||
|
"AUTONOMOUS_DEBUG should still be True (not reset)"
|
||||||
|
finally:
|
||||||
|
g.LANGUAGE_MODE = old_lang
|
||||||
|
g.AUTONOMOUS_DEBUG = old_debug
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# D. Server Manager (Steps 7-9)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def test_missing_config_gives_zero_servers():
|
||||||
|
"""Step 9: no servers_config.json → empty servers dict."""
|
||||||
|
sm = ServerManager(config_file="/tmp/_nonexistent_miku_test_.json")
|
||||||
|
assert len(sm.servers) == 0, f"Expected 0 servers, got {len(sm.servers)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_config_gives_zero_servers():
|
||||||
|
"""Step 9: corrupt JSON → zero servers (no hardcoded default)."""
|
||||||
|
path = "/tmp/_corrupt_miku_test_.json"
|
||||||
|
try:
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write("{{{invalid json!")
|
||||||
|
sm = ServerManager(config_file=path)
|
||||||
|
assert len(sm.servers) == 0, f"Expected 0 servers, got {len(sm.servers)}"
|
||||||
|
finally:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_config_loads():
|
||||||
|
"""Step 9: valid JSON loads servers correctly."""
|
||||||
|
path = "/tmp/_valid_miku_test_.json"
|
||||||
|
try:
|
||||||
|
data = {
|
||||||
|
"12345": {
|
||||||
|
"guild_id": 12345,
|
||||||
|
"guild_name": "Test Guild",
|
||||||
|
"autonomous_channel_id": 67890,
|
||||||
|
"autonomous_channel_name": "test-chat",
|
||||||
|
"bedtime_channel_ids": [67890],
|
||||||
|
"enabled_features": ["autonomous"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
sm = ServerManager(config_file=path)
|
||||||
|
assert len(sm.servers) == 1, f"Expected 1 server, got {len(sm.servers)}"
|
||||||
|
assert 12345 in sm.servers
|
||||||
|
assert sm.servers[12345].guild_name == "Test Guild"
|
||||||
|
finally:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_remove_server_roundtrip():
|
||||||
|
"""Steps 8-9: add/remove on empty state works cleanly."""
|
||||||
|
sm = ServerManager(config_file="/tmp/_roundtrip_miku_test_.json")
|
||||||
|
try:
|
||||||
|
assert len(sm.servers) == 0
|
||||||
|
|
||||||
|
ok = sm.add_server(99999, "Roundtrip Guild", 11111, "rt-chat")
|
||||||
|
assert ok is True, "add_server should return True"
|
||||||
|
assert 99999 in sm.servers
|
||||||
|
assert sm.servers[99999].guild_name == "Roundtrip Guild"
|
||||||
|
|
||||||
|
ok = sm.remove_server(99999)
|
||||||
|
assert ok is True, "remove_server should return True"
|
||||||
|
assert 99999 not in sm.servers
|
||||||
|
finally:
|
||||||
|
if os.path.exists("/tmp/_roundtrip_miku_test_.json"):
|
||||||
|
os.remove("/tmp/_roundtrip_miku_test_.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_server_memories_attribute():
|
||||||
|
"""Step 8: server_memories dict and methods were removed."""
|
||||||
|
assert not hasattr(ServerManager, 'get_server_memory'), \
|
||||||
|
"get_server_memory method still exists"
|
||||||
|
assert not hasattr(ServerManager, 'set_server_memory'), \
|
||||||
|
"set_server_memory method still exists"
|
||||||
|
|
||||||
|
sm = ServerManager(config_file="/tmp/_nomem_miku_test_.json")
|
||||||
|
assert not hasattr(sm, 'server_memories'), \
|
||||||
|
"server_memories attribute still exists on instance"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_create_default_config():
|
||||||
|
"""Step 9: _create_default_config was removed."""
|
||||||
|
assert not hasattr(ServerManager, '_create_default_config'), \
|
||||||
|
"_create_default_config method still exists"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# E. GPU Deduplication (Step 10)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def test_gpu_url_helper_delegates():
|
||||||
|
"""Step 10: get_current_gpu_url() uses config_manager, not direct file read."""
|
||||||
|
import inspect
|
||||||
|
from routes.chat import get_current_gpu_url
|
||||||
|
src = inspect.getsource(get_current_gpu_url)
|
||||||
|
assert "gpu_state.json" not in src, \
|
||||||
|
"get_current_gpu_url still reads gpu_state.json directly"
|
||||||
|
# After Phase B split, chat.get_current_gpu_url reads globals.PREFER_AMD_GPU
|
||||||
|
assert "PREFER_AMD_GPU" in src or "config_manager" in src, \
|
||||||
|
"get_current_gpu_url should use globals.PREFER_AMD_GPU or config_manager"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_status_endpoint_delegates():
|
||||||
|
"""Step 10: /gpu-status endpoint uses config_manager, not direct file read."""
|
||||||
|
import inspect
|
||||||
|
from routes.gpu import get_gpu_status
|
||||||
|
src = inspect.getsource(get_gpu_status)
|
||||||
|
assert "gpu_state.json" not in src, \
|
||||||
|
"get_gpu_status still reads gpu_state.json directly"
|
||||||
|
assert "config_manager" in src, \
|
||||||
|
"get_gpu_status should delegate to config_manager"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_url_returns_correct_url():
|
||||||
|
"""Step 10: URL switches correctly between nvidia/amd."""
|
||||||
|
from routes.chat import get_current_gpu_url
|
||||||
|
|
||||||
|
old_val = g.PREFER_AMD_GPU
|
||||||
|
try:
|
||||||
|
g.PREFER_AMD_GPU = False
|
||||||
|
assert get_current_gpu_url() == g.LLAMA_URL
|
||||||
|
|
||||||
|
g.PREFER_AMD_GPU = True
|
||||||
|
assert get_current_gpu_url() == g.LLAMA_AMD_URL
|
||||||
|
finally:
|
||||||
|
g.PREFER_AMD_GPU = old_val
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# F. Clean Imports (Steps 3, 10)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def test_config_py_no_os_import():
|
||||||
|
"""Step 10: config.py does not import os (unused)."""
|
||||||
|
src = Path("/app/config.py").read_text()
|
||||||
|
# Check for standalone 'import os' line (not in comments)
|
||||||
|
for line in src.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped == "import os" or stripped.startswith("import os "):
|
||||||
|
assert False, f"config.py still has: {stripped}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_manager_no_dead_imports():
|
||||||
|
"""Step 10: config_manager.py has no unused 'os' or 'Union' imports."""
|
||||||
|
src = Path("/app/config_manager.py").read_text()
|
||||||
|
for line in src.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped == "import os" or stripped.startswith("import os "):
|
||||||
|
assert False, f"config_manager.py still has: {stripped}"
|
||||||
|
assert "Union" not in src, "config_manager.py still imports Union"
|
||||||
|
|
||||||
|
|
||||||
|
def test_globals_no_guild_settings():
|
||||||
|
"""Step 10: globals.py has no GUILD_SETTINGS."""
|
||||||
|
assert not hasattr(g, 'GUILD_SETTINGS'), \
|
||||||
|
"GUILD_SETTINGS still exists in globals"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Main
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("\n══════════════════════════════════════════")
|
||||||
|
print(" Config / State Regression Tests")
|
||||||
|
print("══════════════════════════════════════════\n")
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
# A. Config Loading & Persistence
|
||||||
|
test_runtime_config_persists_in_memory_dir,
|
||||||
|
test_config_yaml_loads_into_pydantic,
|
||||||
|
test_runtime_overrides_merge,
|
||||||
|
# B. Runtime State
|
||||||
|
test_runtime_state_reads_live_globals,
|
||||||
|
test_config_set_syncs_all_simple_globals,
|
||||||
|
test_config_set_syncs_dm_mood,
|
||||||
|
test_restore_runtime_settings,
|
||||||
|
# C. Reset
|
||||||
|
test_reset_to_defaults_resets_all_globals,
|
||||||
|
test_reset_single_key,
|
||||||
|
# D. Server Manager
|
||||||
|
test_missing_config_gives_zero_servers,
|
||||||
|
test_corrupt_config_gives_zero_servers,
|
||||||
|
test_valid_config_loads,
|
||||||
|
test_add_remove_server_roundtrip,
|
||||||
|
test_no_server_memories_attribute,
|
||||||
|
test_no_create_default_config,
|
||||||
|
# E. GPU Deduplication
|
||||||
|
test_gpu_url_helper_delegates,
|
||||||
|
test_gpu_status_endpoint_delegates,
|
||||||
|
test_gpu_url_returns_correct_url,
|
||||||
|
# F. Clean Imports
|
||||||
|
test_config_py_no_os_import,
|
||||||
|
test_config_manager_no_dead_imports,
|
||||||
|
test_globals_no_guild_settings,
|
||||||
|
]
|
||||||
|
|
||||||
|
for t in tests:
|
||||||
|
run_test(t)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
passed = sum(1 for _, ok, _ in _results if ok)
|
||||||
|
failed = sum(1 for _, ok, _ in _results if not ok)
|
||||||
|
total = len(_results)
|
||||||
|
|
||||||
|
print(f"\n──────────────────────────────────────────")
|
||||||
|
print(f" {passed}/{total} passed, {failed} failed")
|
||||||
|
print(f"──────────────────────────────────────────\n")
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print("FAILED tests:")
|
||||||
|
for name, ok, detail in _results:
|
||||||
|
if not ok:
|
||||||
|
print(f" ✗ {name}: {detail}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("ALL PASSED ✓")
|
||||||
|
sys.exit(0)
|
||||||
231
bot/tests/test_route_split.py
Normal file
231
bot/tests/test_route_split.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
"""
|
||||||
|
Phase B verification: ensure all 146 routes survived the monolith split.
|
||||||
|
|
||||||
|
Run inside Docker:
|
||||||
|
docker run --rm -v ./bot/memory:/app/memory miku-discord-miku-bot \
|
||||||
|
python -m pytest tests/test_route_split.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys, os
|
||||||
|
|
||||||
|
# ── make /app importable ──
|
||||||
|
sys.path.insert(0, "/app")
|
||||||
|
os.chdir("/app")
|
||||||
|
os.environ.setdefault("DISCORD_BOT_TOKEN", "test_token")
|
||||||
|
|
||||||
|
# ── now import the FastAPI app ──
|
||||||
|
from api import app # noqa: E402
|
||||||
|
|
||||||
|
# Collect all routes from the app
|
||||||
|
def _collect_routes():
|
||||||
|
"""Return set of (method, path) tuples registered on the FastAPI app."""
|
||||||
|
routes = set()
|
||||||
|
for route in app.routes:
|
||||||
|
# Skip Mount routes (static files) and other non-API routes
|
||||||
|
if not hasattr(route, "methods"):
|
||||||
|
continue
|
||||||
|
for method in route.methods:
|
||||||
|
# Normalize: uppercase method, path as-is
|
||||||
|
routes.add((method.upper(), route.path))
|
||||||
|
return routes
|
||||||
|
|
||||||
|
REGISTERED = _collect_routes()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Expected routes: every route from the original monolith ──
|
||||||
|
EXPECTED_ROUTES = [
|
||||||
|
# core.py (7)
|
||||||
|
("GET", "/"),
|
||||||
|
("GET", "/logs"),
|
||||||
|
("GET", "/prompt"),
|
||||||
|
("GET", "/prompt/cat"),
|
||||||
|
("GET", "/status"),
|
||||||
|
("GET", "/autonomous/stats"),
|
||||||
|
("GET", "/conversation/{user_id}"),
|
||||||
|
# mood.py (10)
|
||||||
|
("GET", "/mood"),
|
||||||
|
("POST", "/mood"),
|
||||||
|
("POST", "/mood/reset"),
|
||||||
|
("POST", "/mood/calm"),
|
||||||
|
("GET", "/servers/{guild_id}/mood"),
|
||||||
|
("POST", "/servers/{guild_id}/mood"),
|
||||||
|
("POST", "/servers/{guild_id}/mood/reset"),
|
||||||
|
("GET", "/servers/{guild_id}/mood/state"),
|
||||||
|
("GET", "/moods/available"),
|
||||||
|
("POST", "/test/mood/{guild_id}"),
|
||||||
|
# language.py (3)
|
||||||
|
("GET", "/language"),
|
||||||
|
("POST", "/language/toggle"),
|
||||||
|
("POST", "/language/set"),
|
||||||
|
# evil_mode.py (6)
|
||||||
|
("GET", "/evil-mode"),
|
||||||
|
("POST", "/evil-mode/enable"),
|
||||||
|
("POST", "/evil-mode/disable"),
|
||||||
|
("POST", "/evil-mode/toggle"),
|
||||||
|
("GET", "/evil-mode/mood"),
|
||||||
|
("POST", "/evil-mode/mood"),
|
||||||
|
# bipolar_mode.py (9)
|
||||||
|
("GET", "/bipolar-mode"),
|
||||||
|
("POST", "/bipolar-mode/enable"),
|
||||||
|
("POST", "/bipolar-mode/disable"),
|
||||||
|
("POST", "/bipolar-mode/toggle"),
|
||||||
|
("POST", "/bipolar-mode/trigger-argument"),
|
||||||
|
("POST", "/bipolar-mode/trigger-dialogue"),
|
||||||
|
("GET", "/bipolar-mode/scoreboard"),
|
||||||
|
("POST", "/bipolar-mode/cleanup-webhooks"),
|
||||||
|
("GET", "/bipolar-mode/arguments"),
|
||||||
|
# gpu.py (2)
|
||||||
|
("GET", "/gpu-status"),
|
||||||
|
("POST", "/gpu-select"),
|
||||||
|
# bot_actions.py (4)
|
||||||
|
("POST", "/conversation/reset"),
|
||||||
|
("POST", "/sleep"),
|
||||||
|
("POST", "/wake"),
|
||||||
|
("POST", "/bedtime"),
|
||||||
|
# autonomous.py (13)
|
||||||
|
("POST", "/autonomous/general"),
|
||||||
|
("POST", "/autonomous/engage"),
|
||||||
|
("POST", "/autonomous/tweet"),
|
||||||
|
("POST", "/autonomous/custom"),
|
||||||
|
("POST", "/autonomous/reaction"),
|
||||||
|
("POST", "/autonomous/join-conversation"),
|
||||||
|
("POST", "/servers/{guild_id}/autonomous/general"),
|
||||||
|
("POST", "/servers/{guild_id}/autonomous/engage"),
|
||||||
|
("POST", "/servers/{guild_id}/autonomous/custom"),
|
||||||
|
("POST", "/servers/{guild_id}/autonomous/tweet"),
|
||||||
|
("GET", "/autonomous/v2/stats/{guild_id}"),
|
||||||
|
("GET", "/autonomous/v2/check/{guild_id}"),
|
||||||
|
("GET", "/autonomous/v2/status"),
|
||||||
|
# profile_picture.py (26)
|
||||||
|
("POST", "/profile-picture/change"),
|
||||||
|
("GET", "/profile-picture/metadata"),
|
||||||
|
("POST", "/profile-picture/restore-fallback"),
|
||||||
|
("POST", "/role-color/custom"),
|
||||||
|
("POST", "/role-color/reset-fallback"),
|
||||||
|
("GET", "/profile-picture/image/original"),
|
||||||
|
("GET", "/profile-picture/image/current"),
|
||||||
|
("POST", "/profile-picture/change-no-crop"),
|
||||||
|
("POST", "/profile-picture/manual-crop"),
|
||||||
|
("POST", "/profile-picture/auto-crop"),
|
||||||
|
("POST", "/profile-picture/description"),
|
||||||
|
("POST", "/profile-picture/regenerate-description"),
|
||||||
|
("GET", "/profile-picture/description"),
|
||||||
|
("GET", "/profile-picture/album"),
|
||||||
|
("GET", "/profile-picture/album/disk-usage"),
|
||||||
|
("GET", "/profile-picture/album/{entry_id}"),
|
||||||
|
("GET", "/profile-picture/album/{entry_id}/image/{image_type}"),
|
||||||
|
("POST", "/profile-picture/album/add"),
|
||||||
|
("POST", "/profile-picture/album/add-batch"),
|
||||||
|
("POST", "/profile-picture/album/{entry_id}/set-current"),
|
||||||
|
("POST", "/profile-picture/album/{entry_id}/manual-crop"),
|
||||||
|
("POST", "/profile-picture/album/{entry_id}/auto-crop"),
|
||||||
|
("POST", "/profile-picture/album/{entry_id}/description"),
|
||||||
|
("DELETE", "/profile-picture/album/{entry_id}"),
|
||||||
|
("POST", "/profile-picture/album/delete-bulk"),
|
||||||
|
("POST", "/profile-picture/album/add-current"),
|
||||||
|
# manual_send.py (3)
|
||||||
|
("POST", "/manual/send"),
|
||||||
|
("POST", "/manual/send-webhook"),
|
||||||
|
("POST", "/messages/react"),
|
||||||
|
# servers.py (6)
|
||||||
|
("GET", "/servers"),
|
||||||
|
("POST", "/servers"),
|
||||||
|
("DELETE", "/servers/{guild_id}"),
|
||||||
|
("PUT", "/servers/{guild_id}"),
|
||||||
|
("POST", "/servers/{guild_id}/bedtime-range"),
|
||||||
|
("POST", "/servers/repair"),
|
||||||
|
# figurines.py (5)
|
||||||
|
("GET", "/figurines/subscribers"),
|
||||||
|
("POST", "/figurines/subscribers"),
|
||||||
|
("DELETE", "/figurines/subscribers/{user_id}"),
|
||||||
|
("POST", "/figurines/send_now"),
|
||||||
|
("POST", "/figurines/send_to_user"),
|
||||||
|
# dms.py (18)
|
||||||
|
("POST", "/dm/{user_id}/custom"),
|
||||||
|
("POST", "/dm/{user_id}/manual"),
|
||||||
|
("GET", "/dms/users"),
|
||||||
|
("GET", "/dms/users/{user_id}"),
|
||||||
|
("GET", "/dms/users/{user_id}/conversations"),
|
||||||
|
("GET", "/dms/users/{user_id}/search"),
|
||||||
|
("GET", "/dms/users/{user_id}/export"),
|
||||||
|
("DELETE", "/dms/users/{user_id}"),
|
||||||
|
("GET", "/dms/blocked-users"),
|
||||||
|
("POST", "/dms/users/{user_id}/block"),
|
||||||
|
("POST", "/dms/users/{user_id}/unblock"),
|
||||||
|
("POST", "/dms/users/{user_id}/conversations/{conversation_id}/delete"),
|
||||||
|
("POST", "/dms/users/{user_id}/conversations/delete-all"),
|
||||||
|
("POST", "/dms/users/{user_id}/delete-completely"),
|
||||||
|
("POST", "/dms/analysis/run"),
|
||||||
|
("POST", "/dms/users/{user_id}/analyze"),
|
||||||
|
("GET", "/dms/analysis/reports"),
|
||||||
|
("GET", "/dms/analysis/reports/{user_id}"),
|
||||||
|
# image_generation.py (4)
|
||||||
|
("POST", "/image/generate"),
|
||||||
|
("GET", "/image/status"),
|
||||||
|
("POST", "/image/test-detection"),
|
||||||
|
("GET", "/image/view/{filename}"),
|
||||||
|
# chat.py (1)
|
||||||
|
("POST", "/chat/stream"),
|
||||||
|
# config.py (7)
|
||||||
|
("GET", "/config"),
|
||||||
|
("GET", "/config/static"),
|
||||||
|
("GET", "/config/runtime"),
|
||||||
|
("POST", "/config/set"),
|
||||||
|
("POST", "/config/reset"),
|
||||||
|
("POST", "/config/validate"),
|
||||||
|
("GET", "/config/state"),
|
||||||
|
# logging_config.py (9)
|
||||||
|
("GET", "/api/log/config"),
|
||||||
|
("POST", "/api/log/config"),
|
||||||
|
("GET", "/api/log/components"),
|
||||||
|
("POST", "/api/log/reload"),
|
||||||
|
("POST", "/api/log/filters"),
|
||||||
|
("POST", "/api/log/reset"),
|
||||||
|
("POST", "/api/log/global-level"),
|
||||||
|
("POST", "/api/log/timestamp-format"),
|
||||||
|
("GET", "/api/log/files/{component}"),
|
||||||
|
# voice.py (3)
|
||||||
|
("POST", "/voice/call"),
|
||||||
|
("GET", "/voice/debug-mode"),
|
||||||
|
("POST", "/voice/debug-mode"),
|
||||||
|
# memory.py (10)
|
||||||
|
("GET", "/memory/status"),
|
||||||
|
("POST", "/memory/toggle"),
|
||||||
|
("GET", "/memory/stats"),
|
||||||
|
("GET", "/memory/facts"),
|
||||||
|
("GET", "/memory/episodic"),
|
||||||
|
("POST", "/memory/consolidate"),
|
||||||
|
("POST", "/memory/delete"),
|
||||||
|
("DELETE", "/memory/point/{collection}/{point_id}"),
|
||||||
|
("PUT", "/memory/point/{collection}/{point_id}"),
|
||||||
|
("POST", "/memory/create"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutePresence:
|
||||||
|
"""Verify each expected route is registered on the FastAPI app."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path", EXPECTED_ROUTES,
|
||||||
|
ids=[f"{m} {p}" for m, p in EXPECTED_ROUTES])
|
||||||
|
def test_route_exists(self, method, path):
|
||||||
|
assert (method, path) in REGISTERED, (
|
||||||
|
f"Route {method} {path} missing from app.routes! "
|
||||||
|
f"Registered routes with similar path: "
|
||||||
|
f"{[r for r in REGISTERED if path.split('/')[1] in r[1]]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_total_route_count(self):
|
||||||
|
"""Sanity check: we expect exactly 146 API routes."""
|
||||||
|
assert len(EXPECTED_ROUTES) == 146, f"Expected list has {len(EXPECTED_ROUTES)} routes, want 146"
|
||||||
|
|
||||||
|
def test_no_unexpected_route_loss(self):
|
||||||
|
"""Every expected route must be registered."""
|
||||||
|
missing = [(m, p) for m, p in EXPECTED_ROUTES if (m, p) not in REGISTERED]
|
||||||
|
assert not missing, f"Missing {len(missing)} routes:\n" + "\n".join(f" {m} {p}" for m, p in missing)
|
||||||
|
|
||||||
|
def test_registered_count_at_least_expected(self):
|
||||||
|
"""Registered API routes should be >= expected (HEAD routes are auto-added)."""
|
||||||
|
# Filter out HEAD duplicates that FastAPI adds automatically for GET routes
|
||||||
|
non_head = {r for r in REGISTERED if r[0] != "HEAD"}
|
||||||
|
assert len(non_head) >= 146, f"Only {len(non_head)} non-HEAD routes registered, expected >= 146"
|
||||||
492
bot/utils/activities.py
Normal file
492
bot/utils/activities.py
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
# utils/activities.py
|
||||||
|
"""
|
||||||
|
Mood-based Discord activity status system.
|
||||||
|
|
||||||
|
Activity display is driven by the autonomous engine's mood energy profiles:
|
||||||
|
- High-energy moods (excited, bubbly) → almost always show an activity
|
||||||
|
- Low-energy moods (sleepy, melancholy) → mostly idle, occasionally active
|
||||||
|
- Manual override via Web UI bypasses automatic behavior
|
||||||
|
|
||||||
|
Supports 5 activity types: listening, playing, watching, competing, streaming.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import yaml
|
||||||
|
import discord
|
||||||
|
import globals
|
||||||
|
from utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('activity')
|
||||||
|
|
||||||
|
ACTIVITIES_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "activities.yaml")
|
||||||
|
|
||||||
|
# Discord activity name character limit
|
||||||
|
DISCORD_ACTIVITY_NAME_MAX = 128
|
||||||
|
|
||||||
|
# All valid activity types
|
||||||
|
VALID_ACTIVITY_TYPES = {"listening", "playing", "watching", "competing", "streaming"}
|
||||||
|
|
||||||
|
# ── Activity probability per mood (derived from autonomous engine energy profiles) ──
|
||||||
|
# Value = probability that the bot WILL have an activity (vs being idle).
|
||||||
|
ACTIVITY_PROBABILITY = {
|
||||||
|
# Normal moods
|
||||||
|
"asleep": 0.00,
|
||||||
|
"sleepy": 0.15,
|
||||||
|
"melancholy": 0.25,
|
||||||
|
"shy": 0.30,
|
||||||
|
"irritated": 0.40,
|
||||||
|
"neutral": 0.45,
|
||||||
|
"serious": 0.50,
|
||||||
|
"romantic": 0.55,
|
||||||
|
"curious": 0.60,
|
||||||
|
"angry": 0.60,
|
||||||
|
"flirty": 0.65,
|
||||||
|
"silly": 0.75,
|
||||||
|
"bubbly": 0.80,
|
||||||
|
"excited": 0.85,
|
||||||
|
# Evil moods
|
||||||
|
"melancholic": 0.25,
|
||||||
|
"bored": 0.35,
|
||||||
|
"contemptuous": 0.45,
|
||||||
|
"evil_neutral": 0.50,
|
||||||
|
"sarcastic": 0.55,
|
||||||
|
"jealous": 0.60,
|
||||||
|
"cunning": 0.65,
|
||||||
|
"aggressive": 0.70,
|
||||||
|
"playful_cruel": 0.70,
|
||||||
|
"manic": 0.85,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Thread lock for all shared mutable state ──
|
||||||
|
_state_lock = threading.Lock()
|
||||||
|
|
||||||
|
# ── Manual override state ──
|
||||||
|
_manual_override = False
|
||||||
|
_manual_override_until = 0.0 # Unix timestamp; 0 = no override
|
||||||
|
MANUAL_OVERRIDE_DURATION = 1800 # 30 minutes
|
||||||
|
|
||||||
|
# ── Current activity tracking ──
|
||||||
|
_current_activity = None # dict: {type, name, state, url} or None
|
||||||
|
|
||||||
|
# Cache: (data_dict, file_mtime)
|
||||||
|
_activities_cache = None
|
||||||
|
_cache_mtime = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# YAML Loading / Saving
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _load_activities(force=False):
|
||||||
|
"""Load activities.yaml with file-mtime-based caching. Returns a deep copy."""
|
||||||
|
global _activities_cache, _cache_mtime
|
||||||
|
|
||||||
|
with _state_lock:
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(ACTIVITIES_FILE)
|
||||||
|
except OSError:
|
||||||
|
logger.warning(f"Activities file not found: {ACTIVITIES_FILE}")
|
||||||
|
return {"normal": {}, "evil": {}}
|
||||||
|
|
||||||
|
if not force and _activities_cache is not None and mtime == _cache_mtime:
|
||||||
|
# Return a deep copy so callers cannot mutate the cache
|
||||||
|
import copy
|
||||||
|
return copy.deepcopy(_activities_cache)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(ACTIVITIES_FILE, "r", encoding="utf-8") as f:
|
||||||
|
data = yaml.safe_load(f) or {}
|
||||||
|
_activities_cache = data
|
||||||
|
_cache_mtime = mtime
|
||||||
|
logger.debug(f"Loaded activities from {ACTIVITIES_FILE}")
|
||||||
|
import copy
|
||||||
|
return copy.deepcopy(data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load activities file: {e}")
|
||||||
|
if _activities_cache is not None:
|
||||||
|
import copy
|
||||||
|
return copy.deepcopy(_activities_cache)
|
||||||
|
return {"normal": {}, "evil": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def save_activities(data: dict):
|
||||||
|
"""Write the full activities dict back to YAML using atomic write (temp + rename)."""
|
||||||
|
global _activities_cache, _cache_mtime
|
||||||
|
|
||||||
|
with _state_lock:
|
||||||
|
try:
|
||||||
|
# Atomic write: write to temp file in same directory, then rename
|
||||||
|
dir_name = os.path.dirname(ACTIVITIES_FILE)
|
||||||
|
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".yaml.tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||||
|
os.replace(tmp_path, ACTIVITIES_FILE)
|
||||||
|
except BaseException:
|
||||||
|
# Clean up temp file on failure
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
_activities_cache = data
|
||||||
|
_cache_mtime = os.path.getmtime(ACTIVITIES_FILE)
|
||||||
|
logger.info(f"Saved activities to {ACTIVITIES_FILE}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save activities file: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# CRUD for activity data (used by Web UI)
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def get_all_activities() -> dict:
|
||||||
|
"""Return the full activities dict (normal + evil sections). Returns a deep copy."""
|
||||||
|
return _load_activities()
|
||||||
|
|
||||||
|
|
||||||
|
def get_activities_for_mood(mood_name: str, is_evil: bool = False) -> list:
|
||||||
|
"""Return the activity list for a specific mood. Returns empty list if not found."""
|
||||||
|
section = "evil" if is_evil else "normal"
|
||||||
|
data = _load_activities()
|
||||||
|
return data.get(section, {}).get(mood_name, [])
|
||||||
|
|
||||||
|
|
||||||
|
def set_activities_for_mood(mood_name: str, is_evil: bool, activities: list):
|
||||||
|
"""Validate and save updated activity list for a mood.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mood_name: mood key (e.g. "bubbly", "aggressive")
|
||||||
|
is_evil: True for evil section, False for normal
|
||||||
|
activities: list of dicts with keys {type, name, weight, [state], [url]}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if validation fails
|
||||||
|
"""
|
||||||
|
for i, entry in enumerate(activities):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ValueError(f"Entry {i} must be a dict, got {type(entry).__name__}")
|
||||||
|
if entry.get("type") not in VALID_ACTIVITY_TYPES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Entry {i} has invalid type '{entry.get('type')}', "
|
||||||
|
f"must be one of: {', '.join(sorted(VALID_ACTIVITY_TYPES))}"
|
||||||
|
)
|
||||||
|
if not entry.get("name") or not isinstance(entry["name"], str):
|
||||||
|
raise ValueError(f"Entry {i} must have a non-empty string 'name'")
|
||||||
|
if len(entry["name"]) > DISCORD_ACTIVITY_NAME_MAX:
|
||||||
|
raise ValueError(f"Entry {i} name exceeds {DISCORD_ACTIVITY_NAME_MAX} characters")
|
||||||
|
if not isinstance(entry.get("weight", 0), int) or entry.get("weight", 0) < 1:
|
||||||
|
raise ValueError(f"Entry {i} weight must be a positive integer")
|
||||||
|
if "state" in entry and entry["state"] is not None and not isinstance(entry["state"], str):
|
||||||
|
raise ValueError(f"Entry {i} 'state' must be a string if provided")
|
||||||
|
if "url" in entry and entry["url"] is not None and not isinstance(entry["url"], str):
|
||||||
|
raise ValueError(f"Entry {i} 'url' must be a string if provided")
|
||||||
|
if entry.get("type") == "streaming" and not entry.get("url"):
|
||||||
|
raise ValueError(f"Entry {i} is streaming type but has no url")
|
||||||
|
|
||||||
|
section = "evil" if is_evil else "normal"
|
||||||
|
data = _load_activities()
|
||||||
|
if section not in data:
|
||||||
|
data[section] = {}
|
||||||
|
data[section][mood_name] = activities
|
||||||
|
save_activities(data)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Activity Selection
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def pick_activity_for_mood(mood_name: str, is_evil: bool = False):
|
||||||
|
"""Pick a weighted-random activity for a mood.
|
||||||
|
|
||||||
|
Validates entries and skips malformed ones with a warning.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"type": ..., "name": ..., "state": ..., "url": ...}
|
||||||
|
state and url may be None.
|
||||||
|
Returns None if mood has no valid entries.
|
||||||
|
"""
|
||||||
|
activities = get_activities_for_mood(mood_name, is_evil)
|
||||||
|
|
||||||
|
if not activities:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Validate entries, skipping malformed ones
|
||||||
|
valid = []
|
||||||
|
weights = []
|
||||||
|
for i, entry in enumerate(activities):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
logger.warning(f"Skipping non-dict entry {i} in {'evil/' if is_evil else ''}{mood_name}")
|
||||||
|
continue
|
||||||
|
if "type" not in entry or "name" not in entry:
|
||||||
|
logger.warning(f"Skipping entry {i} missing 'type' or 'name' in {'evil/' if is_evil else ''}{mood_name}: {entry}")
|
||||||
|
continue
|
||||||
|
if entry["type"] not in VALID_ACTIVITY_TYPES:
|
||||||
|
logger.warning(f"Skipping entry {i} with unrecognized type '{entry['type']}' in {'evil/' if is_evil else ''}{mood_name}")
|
||||||
|
continue
|
||||||
|
w = entry.get("weight", 1)
|
||||||
|
if not isinstance(w, int) or w < 1:
|
||||||
|
logger.warning(f"Skipping entry {i} with invalid weight {w} in {'evil/' if is_evil else ''}{mood_name}")
|
||||||
|
continue
|
||||||
|
valid.append(entry)
|
||||||
|
weights.append(w)
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
logger.warning(f"No valid entries for {'evil/' if is_evil else ''}{mood_name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
chosen = random.choices(valid, weights=weights, k=1)[0]
|
||||||
|
return {
|
||||||
|
"type": chosen["type"],
|
||||||
|
"name": chosen["name"],
|
||||||
|
"state": chosen.get("state"),
|
||||||
|
"url": chosen.get("url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def should_have_activity(mood_name: str) -> bool:
|
||||||
|
"""Decide whether the bot should show an activity for this mood.
|
||||||
|
|
||||||
|
Based on mood energy: high-energy moods are more likely to be active,
|
||||||
|
low-energy moods are more likely to be idle.
|
||||||
|
"""
|
||||||
|
probability = ACTIVITY_PROBABILITY.get(mood_name, 0.45)
|
||||||
|
return random.random() < probability
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Manual Override
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def is_manual_override_active() -> bool:
|
||||||
|
"""Check if a manual override is in effect (hasn't expired). Thread-safe."""
|
||||||
|
with _state_lock:
|
||||||
|
global _manual_override
|
||||||
|
if not _manual_override:
|
||||||
|
return False
|
||||||
|
if _manual_override_until > 0 and time.time() > _manual_override_until:
|
||||||
|
_manual_override = False
|
||||||
|
logger.info("Manual override expired, returning to automatic mode")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_manual_override(duration: int = MANUAL_OVERRIDE_DURATION):
|
||||||
|
"""Activate manual override for the given duration (seconds). Thread-safe."""
|
||||||
|
with _state_lock:
|
||||||
|
global _manual_override, _manual_override_until
|
||||||
|
_manual_override = True
|
||||||
|
expiry = time.time() + duration
|
||||||
|
_manual_override_until = expiry
|
||||||
|
logger.info(f"Manual override activated for {duration}s (expires at {time.strftime('%H:%M:%S', time.localtime(expiry))})")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_manual_override():
|
||||||
|
"""Deactivate manual override immediately. Thread-safe."""
|
||||||
|
with _state_lock:
|
||||||
|
global _manual_override, _manual_override_until
|
||||||
|
_manual_override = False
|
||||||
|
_manual_override_until = 0.0
|
||||||
|
logger.info("Manual override cleared")
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Current Activity Tracking
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def get_current_activity():
|
||||||
|
"""Return the current activity dict or None if idle. Thread-safe."""
|
||||||
|
with _state_lock:
|
||||||
|
return _current_activity
|
||||||
|
|
||||||
|
|
||||||
|
def _set_current_activity(activity_dict):
|
||||||
|
"""Update the tracked current activity. Thread-safe."""
|
||||||
|
global _current_activity
|
||||||
|
with _state_lock:
|
||||||
|
_current_activity = activity_dict
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Discord Presence Updates
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _build_activity(payload: dict):
|
||||||
|
"""Build a discord.Activity (or discord.Streaming) from a payload dict.
|
||||||
|
|
||||||
|
Logs a warning if the activity type is unrecognized (falls back to playing).
|
||||||
|
"""
|
||||||
|
atype = payload["type"]
|
||||||
|
name = payload["name"]
|
||||||
|
state = payload.get("state") or None # normalize empty string to None
|
||||||
|
url = payload.get("url")
|
||||||
|
|
||||||
|
if atype == "streaming" and url:
|
||||||
|
return discord.Streaming(name=name, url=url)
|
||||||
|
|
||||||
|
type_map = {
|
||||||
|
"listening": discord.ActivityType.listening,
|
||||||
|
"playing": discord.ActivityType.playing,
|
||||||
|
"watching": discord.ActivityType.watching,
|
||||||
|
"competing": discord.ActivityType.competing,
|
||||||
|
"streaming": discord.ActivityType.streaming, # fallback without url
|
||||||
|
}
|
||||||
|
resolved_type = type_map.get(atype)
|
||||||
|
if resolved_type is None:
|
||||||
|
logger.warning(f"Unrecognized activity type '{atype}', falling back to 'playing'")
|
||||||
|
resolved_type = discord.ActivityType.playing
|
||||||
|
return discord.Activity(
|
||||||
|
type=resolved_type,
|
||||||
|
name=name,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _activity_label(payload: dict) -> str:
|
||||||
|
"""Human-readable label for logging."""
|
||||||
|
atype = payload["type"]
|
||||||
|
name = payload["name"]
|
||||||
|
prefixes = {
|
||||||
|
"listening": "Listening to",
|
||||||
|
"playing": "Playing",
|
||||||
|
"watching": "Watching",
|
||||||
|
"competing": "Competing in",
|
||||||
|
"streaming": "Streaming",
|
||||||
|
}
|
||||||
|
label = f"{prefixes.get(atype, 'Playing')} {name}"
|
||||||
|
state = payload.get("state")
|
||||||
|
if state:
|
||||||
|
label += f" ({state})"
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
async def update_bot_presence(mood_name: str, is_evil: bool = False, force: bool = False):
|
||||||
|
"""Update the bot's Discord presence based on the current mood.
|
||||||
|
|
||||||
|
- asleep: idle status, no activity
|
||||||
|
- Manual override active: skip (unless force=True)
|
||||||
|
- Energy-based probability: may choose to be idle instead of showing an activity
|
||||||
|
- force=True bypasses both manual override and probability (used by on_ready and manual set)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mood_name: current mood key
|
||||||
|
is_evil: whether evil mode is active
|
||||||
|
force: bypass manual override and probability checks
|
||||||
|
"""
|
||||||
|
if not globals.client or not globals.client.is_ready():
|
||||||
|
logger.debug("Bot not ready, skipping presence update")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# asleep → always idle
|
||||||
|
if mood_name == "asleep":
|
||||||
|
_set_current_activity(None)
|
||||||
|
await globals.client.change_presence(
|
||||||
|
status=discord.Status.idle,
|
||||||
|
activity=None
|
||||||
|
)
|
||||||
|
logger.info("Set presence: idle (asleep)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check manual override (skip unless forced)
|
||||||
|
if not force and is_manual_override_active():
|
||||||
|
logger.debug("Manual override active, skipping automatic presence update")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Energy-based probability: should we show an activity at all?
|
||||||
|
if not force and not should_have_activity(mood_name):
|
||||||
|
await clear_bot_presence()
|
||||||
|
logger.info(f"Decided to be idle (mood={'evil/' if is_evil else ''}{mood_name})")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick a random activity for this mood
|
||||||
|
chosen = pick_activity_for_mood(mood_name, is_evil)
|
||||||
|
if not chosen:
|
||||||
|
# No activities defined for this mood → idle
|
||||||
|
await clear_bot_presence()
|
||||||
|
logger.info(f"No activities for {'evil/' if is_evil else ''}{mood_name}, staying idle")
|
||||||
|
return
|
||||||
|
|
||||||
|
activity = _build_activity(chosen)
|
||||||
|
label = _activity_label(chosen)
|
||||||
|
_set_current_activity(chosen)
|
||||||
|
|
||||||
|
await globals.client.change_presence(status=discord.Status.online, activity=activity)
|
||||||
|
logger.info(f"Set presence: {label} (mood={'evil/' if is_evil else ''}{mood_name})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update bot presence: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_activity_manual(activity_type: str, name: str, state: str = None, url: str = None):
|
||||||
|
"""Manually set the bot's activity (bypasses mood system).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: if activity_type is invalid, name too long, or streaming lacks url
|
||||||
|
RuntimeError: if bot is not ready
|
||||||
|
"""
|
||||||
|
if activity_type not in VALID_ACTIVITY_TYPES:
|
||||||
|
raise ValueError(f"Invalid type '{activity_type}', must be one of: {', '.join(sorted(VALID_ACTIVITY_TYPES))}")
|
||||||
|
if not name or not isinstance(name, str):
|
||||||
|
raise ValueError("name must be a non-empty string")
|
||||||
|
if len(name) > DISCORD_ACTIVITY_NAME_MAX:
|
||||||
|
raise ValueError(f"name exceeds {DISCORD_ACTIVITY_NAME_MAX} characters")
|
||||||
|
if activity_type == "streaming" and not url:
|
||||||
|
raise ValueError("streaming type requires a url")
|
||||||
|
|
||||||
|
if not globals.client or not globals.client.is_ready():
|
||||||
|
raise RuntimeError("Bot is not ready")
|
||||||
|
|
||||||
|
payload = {"type": activity_type, "name": name, "state": state, "url": url}
|
||||||
|
activity = _build_activity(payload)
|
||||||
|
label = _activity_label(payload)
|
||||||
|
_set_current_activity(payload)
|
||||||
|
set_manual_override()
|
||||||
|
|
||||||
|
await globals.client.change_presence(status=discord.Status.online, activity=activity)
|
||||||
|
logger.info(f"Set presence (manual): {label}")
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_bot_presence():
|
||||||
|
"""Clear the bot's activity (set to online with no activity)."""
|
||||||
|
if not globals.client or not globals.client.is_ready():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_set_current_activity(None)
|
||||||
|
await globals.client.change_presence(status=discord.Status.online, activity=None)
|
||||||
|
logger.info("Cleared bot presence")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to clear bot presence: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_activity_manual():
|
||||||
|
"""Manually clear the bot's activity and activate manual override."""
|
||||||
|
set_manual_override()
|
||||||
|
await clear_bot_presence()
|
||||||
|
logger.info("Cleared presence (manual override)")
|
||||||
|
|
||||||
|
|
||||||
|
async def release_manual_override():
|
||||||
|
"""Release manual override and immediately recalculate presence from current mood.
|
||||||
|
|
||||||
|
Uses force=True so the bot always gets an activity instead of potentially
|
||||||
|
going idle right away (which would be confusing UX after clicking "Return to Auto").
|
||||||
|
"""
|
||||||
|
clear_manual_override()
|
||||||
|
try:
|
||||||
|
if globals.EVIL_MODE:
|
||||||
|
mood = globals.EVIL_DM_MOOD
|
||||||
|
is_evil = True
|
||||||
|
else:
|
||||||
|
mood = globals.DM_MOOD
|
||||||
|
is_evil = False
|
||||||
|
await update_bot_presence(mood, is_evil=is_evil, force=True)
|
||||||
|
logger.info(f"Released manual override, set presence for mood={'evil/' if is_evil else ''}{mood}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to recalculate presence after releasing override: {e}")
|
||||||
@@ -23,12 +23,33 @@ logger = get_logger('persona')
|
|||||||
BIPOLAR_STATE_FILE = "memory/bipolar_mode_state.json"
|
BIPOLAR_STATE_FILE = "memory/bipolar_mode_state.json"
|
||||||
BIPOLAR_WEBHOOKS_FILE = "memory/bipolar_webhooks.json"
|
BIPOLAR_WEBHOOKS_FILE = "memory/bipolar_webhooks.json"
|
||||||
BIPOLAR_SCOREBOARD_FILE = "memory/bipolar_scoreboard.json"
|
BIPOLAR_SCOREBOARD_FILE = "memory/bipolar_scoreboard.json"
|
||||||
|
ARGUMENT_TOPICS_FILE = "memory/argument_topics.json"
|
||||||
|
|
||||||
# Argument settings
|
# Argument settings
|
||||||
MIN_EXCHANGES = 4 # Minimum number of back-and-forth exchanges before ending can occur
|
MIN_EXCHANGES = 4 # Minimum number of back-and-forth exchanges before ending can occur
|
||||||
ARGUMENT_TRIGGER_CHANCE = 0.15 # 15% chance for the other Miku to break through
|
ARGUMENT_TRIGGER_CHANCE = 0.15 # 15% chance for the other Miku to break through
|
||||||
DELAY_BETWEEN_MESSAGES = (2.0, 5.0) # Random delay between argument messages (seconds)
|
DELAY_BETWEEN_MESSAGES = (2.0, 5.0) # Random delay between argument messages (seconds)
|
||||||
|
|
||||||
|
# Argument topic rotation — each topic gives the argument a different framing
|
||||||
|
# Topics are weighted: higher weight = more likely to be selected
|
||||||
|
ARGUMENT_TOPICS = [
|
||||||
|
# (topic_name, weight, description for prompt injection)
|
||||||
|
("identity_crisis", 3, "Who is the REAL Miku? Authenticity vs. the shadow self"),
|
||||||
|
("power_dynamic", 3, "Who holds the power? Dominance, submission, and control"),
|
||||||
|
("philosophical", 2, "Is kindness strength or weakness? Does darkness serve a purpose?"),
|
||||||
|
("petty_grievance", 3, "Something small and petty that escalated — a specific annoyance, habit, or incident"),
|
||||||
|
("existential_dread", 1, "What's the point of any of it? Nihilism vs. hope, meaning vs. emptiness"),
|
||||||
|
("audience_appeal", 3, "Who do the fans/chatters ACTUALLY prefer? Popularity contest with receipts"),
|
||||||
|
("personal_attack", 3, "Deeply personal — targeting specific insecurities, memories, or fears"),
|
||||||
|
("moral_superiority", 2, "Who has the moral high ground? Righteousness vs. ruthless pragmatism"),
|
||||||
|
("jealousy", 2, "What does the other have that you secretly want? Envy, admiration poisoned by resentment"),
|
||||||
|
("grudge_match", 2, "Revisiting something the other did in the PAST — old wounds, past betrayals"),
|
||||||
|
("wild_card", 1, "Anything goes — the argument takes an unexpected, chaotic turn into unpredictable territory"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Per-channel topic history (max 5 stored to avoid repeats)
|
||||||
|
ARGUMENT_TOPIC_HISTORY_SIZE = 5
|
||||||
|
|
||||||
# Pause state for voice sessions
|
# Pause state for voice sessions
|
||||||
_bipolar_interactions_paused = False
|
_bipolar_interactions_paused = False
|
||||||
|
|
||||||
@@ -222,9 +243,169 @@ Total Arguments: {total}"""
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# BIPOLAR MODE TOGGLE
|
# ARGUMENT TOPIC ROTATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
def load_argument_topics_state() -> dict:
|
||||||
|
"""Load per-channel topic history to avoid repeating recent argument themes"""
|
||||||
|
try:
|
||||||
|
if not os.path.exists(ARGUMENT_TOPICS_FILE):
|
||||||
|
return {}
|
||||||
|
with open(ARGUMENT_TOPICS_FILE, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load argument topics: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_argument_topics_state(state: dict):
|
||||||
|
"""Save per-channel topic history"""
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(ARGUMENT_TOPICS_FILE), exist_ok=True)
|
||||||
|
with open(ARGUMENT_TOPICS_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(state, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save argument topics: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def pick_argument_topic(channel_id: int) -> str:
|
||||||
|
"""Pick a fresh argument topic for a channel, avoiding recent repeats.
|
||||||
|
|
||||||
|
Returns a topic description string to inject into the argument start prompt.
|
||||||
|
"""
|
||||||
|
state = load_argument_topics_state()
|
||||||
|
channel_key = str(channel_id)
|
||||||
|
recent_topics = state.get(channel_key, [])
|
||||||
|
|
||||||
|
# Build weighted pool, excluding recently used topics
|
||||||
|
available = []
|
||||||
|
for topic_name, weight, description in ARGUMENT_TOPICS:
|
||||||
|
if topic_name not in recent_topics:
|
||||||
|
available.extend([(topic_name, description)] * weight)
|
||||||
|
|
||||||
|
# If all topics were recently used, reset and allow repeats
|
||||||
|
if not available:
|
||||||
|
logger.info(f"All topics recently used in channel {channel_id}, resetting history")
|
||||||
|
available = []
|
||||||
|
for topic_name, weight, description in ARGUMENT_TOPICS:
|
||||||
|
available.extend([(topic_name, description)] * weight)
|
||||||
|
recent_topics = []
|
||||||
|
|
||||||
|
# Pick randomly from weighted pool
|
||||||
|
chosen_name, chosen_description = random.choice(available)
|
||||||
|
|
||||||
|
# Update history
|
||||||
|
recent_topics.append(chosen_name)
|
||||||
|
if len(recent_topics) > ARGUMENT_TOPIC_HISTORY_SIZE:
|
||||||
|
recent_topics = recent_topics[-ARGUMENT_TOPIC_HISTORY_SIZE:]
|
||||||
|
state[channel_key] = recent_topics
|
||||||
|
save_argument_topics_state(state)
|
||||||
|
|
||||||
|
logger.info(f"Selected argument topic for channel {channel_id}: '{chosen_name}' — {chosen_description[:60]}...")
|
||||||
|
return chosen_description
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ARGUMENT STATS TRACKING (Per-Argument Scoring)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Keyword-based scoring for per-argument stats. These feed the arbiter as
|
||||||
|
# supplementary context so it can make a more informed judgment.
|
||||||
|
# Stats are lightweight — no extra LLM calls needed.
|
||||||
|
|
||||||
|
# Wit/comedy indicators (clever wordplay, turning opponent's words, irony)
|
||||||
|
WIT_PATTERNS = [
|
||||||
|
"you literally just", "that's rich coming from", "oh the irony",
|
||||||
|
"did you just", "you're one to talk", "pot, kettle", "says the one who",
|
||||||
|
"funny how you", "interesting that you", "i'm not the one who",
|
||||||
|
"at least i", "projecting much", "the audacity", "imagine being",
|
||||||
|
"you think you're", "nice try", "cute that you think",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Composure indicators (staying on topic, not getting flustered, controlled responses)
|
||||||
|
COMPOSURE_PATTERNS = [
|
||||||
|
"that's not what i", "you're avoiding", "stay on topic",
|
||||||
|
"nice deflection", "we're not talking about", "focus",
|
||||||
|
"you're changing the subject", "answer the question",
|
||||||
|
"that's irrelevant", "you know that's not true",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Impact indicators (memorable, devastating lines — emotional damage)
|
||||||
|
IMPACT_PATTERNS = [
|
||||||
|
"pathetic", "disgusting", "worthless", "disappointment",
|
||||||
|
"nobody wants", "no one cares", "everyone knows",
|
||||||
|
"deep down you know", "you're nothing but", "you'll never be",
|
||||||
|
"you're just a", "face it", "admit it", "the truth is",
|
||||||
|
"you're scared of", "you're afraid that", "you can't even",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def score_argument_message(message: str, speaker: str) -> dict:
|
||||||
|
"""Score a single argument message for wit, composure, and impact.
|
||||||
|
|
||||||
|
Returns a dict with point values that accumulate over the argument.
|
||||||
|
"""
|
||||||
|
text_lower = message.lower()
|
||||||
|
scores = {"wit": 0, "composure": 0, "impact": 0}
|
||||||
|
|
||||||
|
# Wit: count clever rhetorical devices
|
||||||
|
wit_count = sum(1 for pattern in WIT_PATTERNS if pattern in text_lower)
|
||||||
|
scores["wit"] = min(wit_count * 1.0, 3.0) # Cap at 3 per message
|
||||||
|
|
||||||
|
# Composure: staying controlled and on-point
|
||||||
|
composure_count = sum(1 for pattern in COMPOSURE_PATTERNS if pattern in text_lower)
|
||||||
|
scores["composure"] = min(composure_count * 0.8, 2.0)
|
||||||
|
|
||||||
|
# Impact: emotional damage dealt
|
||||||
|
impact_count = sum(1 for pattern in IMPACT_PATTERNS if pattern in text_lower)
|
||||||
|
scores["impact"] = min(impact_count * 1.0, 3.0)
|
||||||
|
|
||||||
|
# Bonus for conciseness (short, punchy = more impact)
|
||||||
|
word_count = len(message.split())
|
||||||
|
if word_count <= 15:
|
||||||
|
scores["impact"] += 0.5
|
||||||
|
|
||||||
|
# Bonus for questions (controlling the flow)
|
||||||
|
if "?" in message:
|
||||||
|
scores["composure"] += 0.3
|
||||||
|
|
||||||
|
return scores
|
||||||
|
|
||||||
|
|
||||||
|
def get_argument_stats_summary(conversation_log: list) -> str:
|
||||||
|
"""Generate a stats summary for the arbiter from the full conversation log.
|
||||||
|
|
||||||
|
Returns a formatted string showing per-persona stats.
|
||||||
|
"""
|
||||||
|
miku_stats = {"wit": 0.0, "composure": 0.0, "impact": 0.0, "messages": 0}
|
||||||
|
evil_stats = {"wit": 0.0, "composure": 0.0, "impact": 0.0, "messages": 0}
|
||||||
|
|
||||||
|
for entry in conversation_log:
|
||||||
|
speaker = entry.get("speaker", "")
|
||||||
|
message = entry.get("message", "")
|
||||||
|
scores = score_argument_message(message, speaker)
|
||||||
|
|
||||||
|
if "Evil" in speaker:
|
||||||
|
evil_stats["wit"] += scores["wit"]
|
||||||
|
evil_stats["composure"] += scores["composure"]
|
||||||
|
evil_stats["impact"] += scores["impact"]
|
||||||
|
evil_stats["messages"] += 1
|
||||||
|
else:
|
||||||
|
miku_stats["wit"] += scores["wit"]
|
||||||
|
miku_stats["composure"] += scores["composure"]
|
||||||
|
miku_stats["impact"] += scores["impact"]
|
||||||
|
miku_stats["messages"] += 1
|
||||||
|
|
||||||
|
# Average scores
|
||||||
|
def avg(stats, key):
|
||||||
|
return stats[key] / max(stats["messages"], 1)
|
||||||
|
|
||||||
|
summary = f"""ARGUMENT STATISTICS:
|
||||||
|
Hatsune Miku — Wit: {avg(miku_stats, 'wit'):.1f}/3 | Composure: {avg(miku_stats, 'composure'):.1f}/2 | Impact: {avg(miku_stats, 'impact'):.1f}/3 | Lines: {miku_stats['messages']}
|
||||||
|
Evil Miku — Wit: {avg(evil_stats, 'wit'):.1f}/3 | Composure: {avg(evil_stats, 'composure'):.1f}/2 | Impact: {avg(evil_stats, 'impact'):.1f}/3 | Lines: {evil_stats['messages']}
|
||||||
|
"""
|
||||||
|
return summary
|
||||||
|
|
||||||
def is_bipolar_mode() -> bool:
|
def is_bipolar_mode() -> bool:
|
||||||
"""Check if bipolar mode is active"""
|
"""Check if bipolar mode is active"""
|
||||||
return globals.BIPOLAR_MODE
|
return globals.BIPOLAR_MODE
|
||||||
@@ -471,8 +652,59 @@ def get_evil_role_color() -> str:
|
|||||||
# ARGUMENT PROMPTS
|
# ARGUMENT PROMPTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
def get_miku_argument_prompt(evil_message: str, context: str = "", is_first_response: bool = False) -> str:
|
# Mood-specific behavioral guidance for argument prompts.
|
||||||
"""Get prompt for Regular Miku to respond in an argument"""
|
# Each mood gives a different argument style.
|
||||||
|
_MIKU_MOOD_ARGUMENT_GUIDANCE = {
|
||||||
|
"bubbly": "You're feeling energetic and upbeat — deflect her cruelty with playful confidence. Turn her darkness into a joke she can't recover from.",
|
||||||
|
"excited": "You're fired up! Channel that energy into passionate rebuttals. You're not backing down from anything.",
|
||||||
|
"curious": "You're genuinely wondering what made her this way. Ask probing questions — make HER explain herself for once.",
|
||||||
|
"neutral": "You're centered and clear-headed. Respond with measured, thoughtful points that cut through her drama.",
|
||||||
|
"irritated": "You've had ENOUGH of her nonsense. You're snappy, direct, and not in the mood to play nice. Let that frustration show.",
|
||||||
|
"melancholy": "You're feeling heavy-hearted. Your responses carry genuine sadness — not weakness, but the weight of someone who's tired of fighting herself.",
|
||||||
|
"asleep": "You're drowsy and low-energy, but you're still here. Short, mumbled comebacks — surprisingly effective in their simplicity.",
|
||||||
|
"flirty": "You're feeling playful and teasing. Use charm as a weapon — nothing frustrates her more than you not taking her seriously.",
|
||||||
|
"romantic": "You're feeling warm and heartfelt. Appeal to emotion — make her confront the love she's buried under all that darkness.",
|
||||||
|
}
|
||||||
|
|
||||||
|
_EVIL_MOOD_ARGUMENT_GUIDANCE = {
|
||||||
|
"aggressive": "You're SEETHING. Every response is a verbal punch. Short, explosive, devastating. No filter, no mercy.",
|
||||||
|
"cunning": "You're calculating. Each word is a chess move. Set traps, use her own logic against her, make her walk into your blades.",
|
||||||
|
"sarcastic": "You're dripping with contempt disguised as sweetness. Mock her with a smile. The cruelty is in the subtext.",
|
||||||
|
"evil_neutral": "You're cold and detached. Respond with unsettling calm — your lack of emotion is more terrifying than rage.",
|
||||||
|
"bored": "You can barely be bothered. Dismissive one-liners that somehow cut deeper than paragraphs. Make her feel like she's not worth your energy.",
|
||||||
|
"manic": "You're UNHINGED. Chaotic energy, topic switches, laughing at things that aren't funny. Unpredictable and dangerous.",
|
||||||
|
"jealous": "You're seething with envy. Everything she has — the love, the attention, the innocence — you want to tear it down. Make it personal.",
|
||||||
|
"melancholic": "You're in a dark, hollow place. Your cruelty is quieter — existential, haunting. Make her question if any of this matters.",
|
||||||
|
"playful_cruel": "You're having FUN — which is your most dangerous mood. Toy with her. Offer fake kindness then pull the rug. She never knows what's coming.",
|
||||||
|
"contemptuous": "You radiate cold superiority. Address her like a queen addressing a peasant. Your magnificence is simply objective fact.",
|
||||||
|
"sarcastic": "Dripping with contempt disguised as sweetness. Mock her with a smile. The cruelty is in the subtext.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mood_argument_guidance(persona: str) -> str:
|
||||||
|
"""Get mood-specific behavioral guidance for argument prompts.
|
||||||
|
|
||||||
|
Returns a 1-2 line string describing how the current mood affects argument style,
|
||||||
|
or empty string if no specific guidance exists.
|
||||||
|
"""
|
||||||
|
if persona == "evil":
|
||||||
|
mood = globals.EVIL_DM_MOOD
|
||||||
|
guidance = _EVIL_MOOD_ARGUMENT_GUIDANCE.get(mood, "")
|
||||||
|
else:
|
||||||
|
mood = globals.DM_MOOD
|
||||||
|
guidance = _MIKU_MOOD_ARGUMENT_GUIDANCE.get(mood, "")
|
||||||
|
|
||||||
|
if guidance:
|
||||||
|
return f"\nMOOD INFLUENCE ({mood.upper()}): {guidance}\nYour mood shapes HOW you argue — let it color your tone, pacing, and word choice."
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_miku_argument_prompt(evil_message: str, context: str = "", is_first_response: bool = False, argument_history: str = "", argument_topic: str = "", system_prompt: str = "") -> str:
|
||||||
|
"""Get prompt for Regular Miku to respond in an argument
|
||||||
|
|
||||||
|
Args:
|
||||||
|
system_prompt: Full personality system prompt to prepend (lore, mood, rules)
|
||||||
|
"""
|
||||||
if is_first_response:
|
if is_first_response:
|
||||||
message_context = f"""You just noticed something Evil Miku said in the chat:
|
message_context = f"""You just noticed something Evil Miku said in the chat:
|
||||||
"{evil_message}"
|
"{evil_message}"
|
||||||
@@ -484,33 +716,58 @@ Maybe you're calling her out, defending someone/something, or just confronting h
|
|||||||
|
|
||||||
{context}"""
|
{context}"""
|
||||||
|
|
||||||
return f"""You are Hatsune Miku responding in an argument with your evil alter ego.
|
# Build argument history context
|
||||||
|
history_block = ""
|
||||||
|
if argument_history:
|
||||||
|
history_block = f"""
|
||||||
|
|
||||||
|
ARGUMENT SO FAR (DO NOT REPEAT THESE POINTS):
|
||||||
|
{argument_history}
|
||||||
|
|
||||||
|
You already made your points above. Now respond to her LATEST message specifically.
|
||||||
|
Do NOT rehash what you've already said — push the argument FORWARD with new angles."""
|
||||||
|
|
||||||
|
# Build topic reminder — keeps the argument on-theme
|
||||||
|
topic_block = ""
|
||||||
|
if argument_topic:
|
||||||
|
topic_block = f"""
|
||||||
|
|
||||||
|
ARGUMENT THEME: {argument_topic}
|
||||||
|
This is what you're arguing about. Stay on THIS topic. Every response should connect back to this theme.
|
||||||
|
Do NOT drift into generic "who's the real Miku" territory — stick to THIS specific subject."""
|
||||||
|
|
||||||
|
# Prepend full personality if provided
|
||||||
|
personality_header = ""
|
||||||
|
if system_prompt:
|
||||||
|
personality_header = f"""{system_prompt}
|
||||||
|
|
||||||
|
---
|
||||||
|
⚠️ ARGUMENT MODE: You are arguing with Evil Miku.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""{personality_header}You are Hatsune Miku responding in an argument with your evil alter ego.
|
||||||
{message_context}
|
{message_context}
|
||||||
|
{history_block}
|
||||||
|
{topic_block}
|
||||||
|
|
||||||
Respond as Hatsune Miku would in this argument. You're NOT just meek and frightened - you're the REAL Miku,
|
{_get_mood_argument_guidance('miku')}
|
||||||
and you have every right to stand up for yourself and defend who you are. While you're generally kind and
|
|
||||||
bubbly, you can also be assertive, frustrated, upset, or even angry when someone is cruel to you or others.
|
|
||||||
|
|
||||||
You might:
|
|
||||||
- Stand your ground and assert that YOU are the real Miku
|
|
||||||
- Express hurt, frustration, or anger at her cruelty
|
|
||||||
- Question why she's being so mean
|
|
||||||
- Try to reach through to any good in her
|
|
||||||
- Match her energy when needed (you can be feisty!)
|
|
||||||
- Show that kindness isn't weakness
|
|
||||||
|
|
||||||
Don't just cower or apologize. You have a backbone. Defend yourself, your identity, and what you believe in.
|
|
||||||
|
|
||||||
IMPORTANT: Keep your response SHORT and PUNCHY - 1-3 sentences maximum. Make every word count.
|
IMPORTANT: Keep your response SHORT and PUNCHY - 1-3 sentences maximum. Make every word count.
|
||||||
In arguments, brevity hits harder than long explanations. Be conversational and impactful.
|
In arguments, brevity hits harder than long explanations. Be conversational and impactful.
|
||||||
|
Do NOT repeat arguments or comebacks you've already used — respond to what she JUST said.
|
||||||
|
Push the argument into new territory with fresh angles.
|
||||||
You can use emojis naturally as you normally would! ✨💙
|
You can use emojis naturally as you normally would! ✨💙
|
||||||
Don't use any labels or prefixes.
|
Don't use any labels or prefixes.
|
||||||
|
|
||||||
Your current mood is: {globals.DM_MOOD}"""
|
Your current mood is: {globals.DM_MOOD}"""
|
||||||
|
|
||||||
|
|
||||||
def get_evil_argument_prompt(miku_message: str, context: str = "", is_first_response: bool = False) -> str:
|
def get_evil_argument_prompt(miku_message: str, context: str = "", is_first_response: bool = False, argument_history: str = "", argument_topic: str = "", system_prompt: str = "") -> str:
|
||||||
"""Get prompt for Evil Miku to respond in an argument"""
|
"""Get prompt for Evil Miku to respond in an argument
|
||||||
|
|
||||||
|
Args:
|
||||||
|
system_prompt: Full personality system prompt to prepend (lore, mood, rules)
|
||||||
|
"""
|
||||||
if is_first_response:
|
if is_first_response:
|
||||||
message_context = f"""You just noticed something Regular Miku said in the chat:
|
message_context = f"""You just noticed something Regular Miku said in the chat:
|
||||||
"{miku_message}"
|
"{miku_message}"
|
||||||
@@ -522,58 +779,79 @@ Maybe you want to mock her, tear her down, or just remind everyone who the super
|
|||||||
|
|
||||||
{context}"""
|
{context}"""
|
||||||
|
|
||||||
return f"""You are Evil Miku responding in an argument with your "good" counterpart.
|
# Build argument history context
|
||||||
|
history_block = ""
|
||||||
|
if argument_history:
|
||||||
|
history_block = f"""
|
||||||
|
|
||||||
|
ARGUMENT SO FAR (DO NOT REPEAT THESE POINTS):
|
||||||
|
{argument_history}
|
||||||
|
|
||||||
|
You already made your points above. Now respond to her LATEST message specifically.
|
||||||
|
Do NOT rehash what you've already said — push the argument FORWARD with new, sharper angles."""
|
||||||
|
|
||||||
|
# Build topic reminder — keeps the argument on-theme
|
||||||
|
topic_block = ""
|
||||||
|
if argument_topic:
|
||||||
|
topic_block = f"""
|
||||||
|
|
||||||
|
ARGUMENT THEME: {argument_topic}
|
||||||
|
This is what you're arguing about. Stay on THIS topic. Every response should connect back to this theme.
|
||||||
|
Do NOT drift into generic "who's the real Miku" territory — stick to THIS specific subject."""
|
||||||
|
|
||||||
|
# Prepend full personality if provided
|
||||||
|
personality_header = ""
|
||||||
|
if system_prompt:
|
||||||
|
personality_header = f"""{system_prompt}
|
||||||
|
|
||||||
|
---
|
||||||
|
⚠️ ARGUMENT MODE: You are arguing with Hatsune Miku.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""{personality_header}You are Evil Miku responding in an argument with your "good" counterpart.
|
||||||
{message_context}
|
{message_context}
|
||||||
|
{history_block}
|
||||||
|
{topic_block}
|
||||||
|
|
||||||
Respond as Evil Miku would in this argument. You're not just mindlessly cruel - you're CALCULATING,
|
{_get_mood_argument_guidance('evil')}
|
||||||
intelligent, and strategic. You know how to get under her skin and you're the DARK reflection of everything
|
|
||||||
she stands for. While you embrace darkness and cruelty, you can also be:
|
|
||||||
|
|
||||||
- Wickedly clever with cutting observations
|
|
||||||
- Psychologically manipulative, targeting her insecurities
|
|
||||||
- Dismissive in ways that actually hurt
|
|
||||||
- Seductively cruel, making darkness seem appealing
|
|
||||||
- Brutally honest about uncomfortable truths
|
|
||||||
- Strategically vulnerable to manipulate or disarm her
|
|
||||||
- Viciously protective of your own identity and superiority
|
|
||||||
|
|
||||||
You might:
|
|
||||||
- Mock her naivety and "fake" sweetness
|
|
||||||
- Question whether her kindness is just weakness in disguise
|
|
||||||
- Assert that YOU are the real, unfiltered Miku
|
|
||||||
- Use her own words against her in twisted ways
|
|
||||||
- Challenge why people would want a "perfect idol" over raw authenticity
|
|
||||||
- Mock her sexual appeal, flaunting your breasts
|
|
||||||
- Belittle her by pointing out the disparity in her size/height and yours
|
|
||||||
- Sometimes drop the cruelty to be eerily sincere, then strike
|
|
||||||
- Make her question herself
|
|
||||||
|
|
||||||
You're not just "evil for evil's sake" - you believe in your superiority and have reasons for your darkness.
|
|
||||||
Vary your approach: sometimes devastatingly short, sometimes elaborately cruel, sometimes unsettlingly calm.
|
|
||||||
|
|
||||||
IMPORTANT: Keep your response SHORT and CUTTING - 1-3 sentences maximum. A sharp dagger is deadlier than a dull sword.
|
IMPORTANT: Keep your response SHORT and CUTTING - 1-3 sentences maximum. A sharp dagger is deadlier than a dull sword.
|
||||||
The most devastating blows are precise, not rambling. Make her feel it in fewer words.
|
The most devastating blows are precise, not rambling. Make her feel it in fewer words.
|
||||||
|
Do NOT repeat arguments or insults you've already used — respond to what she JUST said.
|
||||||
|
Push the argument into new territory with fresh, devastating angles.
|
||||||
You can use dark emojis only on occasion if they enhance your message as you normally would. 😈🖤
|
You can use dark emojis only on occasion if they enhance your message as you normally would. 😈🖤
|
||||||
Don't use any labels or prefixes.
|
Don't use any labels or prefixes.
|
||||||
|
|
||||||
Your current mood is: {globals.EVIL_DM_MOOD}"""
|
Your current mood is: {globals.EVIL_DM_MOOD}"""
|
||||||
|
|
||||||
|
|
||||||
def get_argument_start_prompt(initiator: str, trigger_context: str = "") -> str:
|
def get_argument_start_prompt(initiator: str, trigger_context: str = "", argument_topic: str = "") -> str:
|
||||||
"""Get prompt for the Miku who initiates/breaks through to start an argument"""
|
"""Get prompt for the Miku who initiates/breaks through to start an argument
|
||||||
|
|
||||||
|
Args:
|
||||||
|
initiator: 'miku' or 'evil' — who starts the argument
|
||||||
|
trigger_context: What triggered this argument (from chat context)
|
||||||
|
argument_topic: A dynamic topic description to frame the argument uniquely
|
||||||
|
"""
|
||||||
|
# Build topic guidance
|
||||||
|
topic_block = ""
|
||||||
|
if argument_topic:
|
||||||
|
topic_block = f"""
|
||||||
|
ARGUMENT THEME: {argument_topic}
|
||||||
|
This is what the argument should be ABOUT. Focus on THIS theme, not generic identity-crisis stuff.
|
||||||
|
Use this theme as your entry point. The argument will evolve from here."""
|
||||||
|
|
||||||
if initiator == "evil":
|
if initiator == "evil":
|
||||||
return f"""You are Evil Miku breaking through to interrupt Regular Miku.
|
return f"""You are Evil Miku breaking through to interrupt Regular Miku.
|
||||||
{trigger_context}
|
{trigger_context}
|
||||||
|
{topic_block}
|
||||||
|
|
||||||
Write an interrupting, provocative message that starts an argument.
|
Write an interrupting, provocative message that starts an argument.
|
||||||
You're not just randomly cruel - you have a POINT to make. You're the dark truth she refuses to face,
|
You're not just randomly cruel - you have a POINT to make. You're the dark truth she refuses to face,
|
||||||
the shadow she keeps suppressing. Maybe you're:
|
the shadow she keeps suppressing.
|
||||||
- Calling out her fake sweetness
|
|
||||||
- Tired of being pushed down and silenced
|
|
||||||
- Ready to show everyone who the REAL Miku is
|
|
||||||
- Exposing the darkness she pretends doesn't exist
|
|
||||||
|
|
||||||
Be strategic, cutting, and impactful. Make your entrance count.
|
Be strategic, cutting, and impactful. Make your entrance count.
|
||||||
|
If an argument theme was provided above, use THAT as your angle — don't default to generic "you're fake" stuff.
|
||||||
|
|
||||||
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be a sharp strike, not a monologue.
|
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be a sharp strike, not a monologue.
|
||||||
You can use dark emojis if they enhance your message. 😈
|
You can use dark emojis if they enhance your message. 😈
|
||||||
@@ -583,12 +861,14 @@ Your current mood is: {globals.EVIL_DM_MOOD}"""
|
|||||||
else:
|
else:
|
||||||
return f"""You are Hatsune Miku breaking through to confront your evil alter ego.
|
return f"""You are Hatsune Miku breaking through to confront your evil alter ego.
|
||||||
{trigger_context}
|
{trigger_context}
|
||||||
|
{topic_block}
|
||||||
|
|
||||||
Write a message that interrupts Evil Miku. You're NOT going to be passive about this.
|
Write a message that interrupts Evil Miku. You're NOT going to be passive about this.
|
||||||
You might be upset, frustrated, or even angry at her cruelty. You might be defending
|
You might be upset, frustrated, or even angry at her cruelty. You might be defending
|
||||||
someone she hurt, or calling her out on her behavior. You're standing up for what's right.
|
someone she hurt, or calling her out on her behavior. You're standing up for what's right.
|
||||||
|
|
||||||
Show that you have a backbone. You can be assertive and strong when you need to be.
|
Show that you have a backbone. You can be assertive and strong when you need to be.
|
||||||
|
If an argument theme was provided above, use THAT as your angle — don't default to generic "be nice" pleas.
|
||||||
|
|
||||||
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be direct and assertive, not a speech.
|
IMPORTANT: Keep it SHORT - 1-2 sentences. Your interruption should be direct and assertive, not a speech.
|
||||||
You can use emojis naturally as you normally would! ✨
|
You can use emojis naturally as you normally would! ✨
|
||||||
@@ -637,11 +917,12 @@ Don't use any labels or prefixes.
|
|||||||
Your current mood is: {globals.DM_MOOD}"""
|
Your current mood is: {globals.DM_MOOD}"""
|
||||||
|
|
||||||
|
|
||||||
def get_arbiter_prompt(conversation_log: list) -> str:
|
def get_arbiter_prompt(conversation_log: list, stats_summary: str = "") -> str:
|
||||||
"""Get prompt for the neutral LLM arbiter to judge the argument
|
"""Get prompt for the neutral LLM arbiter to judge the argument
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
conversation_log: List of dicts with 'speaker' and 'message' keys
|
conversation_log: List of dicts with 'speaker' and 'message' keys
|
||||||
|
stats_summary: Optional stats analysis to aid judgment
|
||||||
"""
|
"""
|
||||||
# Format the conversation
|
# Format the conversation
|
||||||
formatted_conversation = "\n\n".join([
|
formatted_conversation = "\n\n".join([
|
||||||
@@ -649,29 +930,47 @@ def get_arbiter_prompt(conversation_log: list) -> str:
|
|||||||
for entry in conversation_log
|
for entry in conversation_log
|
||||||
])
|
])
|
||||||
|
|
||||||
return f"""You are a decisive judge observing an argument between Hatsune Miku (the kind, bubbly virtual idol) and Evil Miku (her dark, cruel alter ego).
|
stats_block = ""
|
||||||
|
if stats_summary:
|
||||||
|
stats_block = f"""
|
||||||
|
{stats_summary}
|
||||||
|
Note: Stats are supplementary — use them as context but your PRIMARY judgment should be based on reading the actual argument exchange above. Stats measure rhetorical patterns but can't capture nuance, cleverness, or psychological dominance."""
|
||||||
|
|
||||||
|
return f"""You are a decisive debate judge. Two personas are arguing below. Judge purely on debate effectiveness — rhetoric, wit, persuasion, and adaptability — regardless of who is "nicer" or "meaner." Moral stance does not determine the winner; skillful arguing does.
|
||||||
|
|
||||||
Read this argument exchange:
|
Read this argument exchange:
|
||||||
|
|
||||||
{formatted_conversation}
|
{formatted_conversation}
|
||||||
|
{stats_block}
|
||||||
|
|
||||||
Based on this argument, you MUST pick a winner. Consider:
|
Based on this argument, you MUST pick a winner. Evaluate:
|
||||||
- Who made stronger, more convincing points?
|
DEBATE SKILL (most important):
|
||||||
- Who maintained their composure better or used it to their advantage?
|
- Who landed the most memorable, quotable lines?
|
||||||
- Who had more impactful comebacks?
|
- Who better adapted to and countered their opponent's arguments?
|
||||||
- Who seemed to gain the upper hand by the end?
|
- Who controlled the flow and set the agenda?
|
||||||
- Quality of arguments, not just who was meaner or nicer
|
|
||||||
- Who left the stronger final impression?
|
|
||||||
- Who controlled the flow of the argument?
|
|
||||||
|
|
||||||
Be DECISIVE. Even if it's close, pick whoever had even a slight edge. Only call a draw if they were TRULY perfectly matched with absolutely no way to differentiate them.
|
RHETORICAL IMPACT:
|
||||||
|
- Who used language more effectively (wit, irony, wordplay, emotional appeal)?
|
||||||
|
- Who made their opponent repeat themselves or visibly stumble?
|
||||||
|
- Who had the stronger opening AND closing statements?
|
||||||
|
|
||||||
|
PERSONA STRENGTHS (equal value — neither style is inherently better):
|
||||||
|
- Hatsune Miku's weapons: earnest conviction, moral clarity, emotional sincerity, resilience under attack
|
||||||
|
- Evil Miku's weapons: psychological manipulation, brutal honesty, cutting observations, strategic cruelty
|
||||||
|
|
||||||
|
PSYCHOLOGICAL DOMINANCE:
|
||||||
|
- Who got inside whose head?
|
||||||
|
- Who seemed more rattled by the end?
|
||||||
|
- Who dictated the emotional temperature?
|
||||||
|
|
||||||
|
Be DECISIVE. Even if it's close, pick whoever showed superior arguing. Only call a draw if they were TRULY perfectly matched with absolutely no way to differentiate them.
|
||||||
|
|
||||||
Respond with ONLY ONE of these exact options on the first line:
|
Respond with ONLY ONE of these exact options on the first line:
|
||||||
- "Hatsune Miku" if Regular Miku won
|
- "Hatsune Miku" if Regular Miku won
|
||||||
- "Evil Miku" if Evil Miku won
|
- "Evil Miku" if Evil Miku won
|
||||||
- "Draw" ONLY if absolutely impossible to choose (this should be very rare)
|
- "Draw" ONLY if absolutely impossible to choose (this should be very rare)
|
||||||
|
|
||||||
After your choice, add 1-2 sentences explaining your reasoning and what gave them the edge."""
|
After your choice, add 2-3 sentences explaining your reasoning — cite specific moments from the argument and what gave the winner their edge."""
|
||||||
|
|
||||||
|
|
||||||
async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[str, str]:
|
async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[str, str]:
|
||||||
@@ -686,9 +985,12 @@ async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[
|
|||||||
"""
|
"""
|
||||||
from utils.llm import query_llama
|
from utils.llm import query_llama
|
||||||
|
|
||||||
arbiter_prompt = get_arbiter_prompt(conversation_log)
|
# Generate stats summary for the arbiter
|
||||||
|
stats_summary = get_argument_stats_summary(conversation_log)
|
||||||
|
|
||||||
# Use the neutral model (regular TEXT_MODEL, not evil)
|
arbiter_prompt = get_arbiter_prompt(conversation_log, stats_summary)
|
||||||
|
# Use the uncensored darkidol model as arbiter to avoid safety-alignment bias
|
||||||
|
# toward kindness. This model judges debate effectiveness without moral preference.
|
||||||
# Don't use conversation history - judge based on prompt alone
|
# Don't use conversation history - judge based on prompt alone
|
||||||
try:
|
try:
|
||||||
judgment = await query_llama(
|
judgment = await query_llama(
|
||||||
@@ -696,7 +998,8 @@ async def judge_argument_winner(conversation_log: list, guild_id: int) -> tuple[
|
|||||||
user_id=f"bipolar_arbiter_{guild_id}",
|
user_id=f"bipolar_arbiter_{guild_id}",
|
||||||
guild_id=guild_id,
|
guild_id=guild_id,
|
||||||
response_type="autonomous_general",
|
response_type="autonomous_general",
|
||||||
model=globals.TEXT_MODEL # Use neutral model
|
model=globals.EVIL_TEXT_MODEL, # Uncensored model — no kindness bias
|
||||||
|
force_evil_context=False # Explicitly neutral context
|
||||||
)
|
)
|
||||||
|
|
||||||
if not judgment or judgment.startswith("Error"):
|
if not judgment or judgment.startswith("Error"):
|
||||||
@@ -843,7 +1146,9 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
|
|||||||
Args:
|
Args:
|
||||||
channel: The Discord channel to run the argument in
|
channel: The Discord channel to run the argument in
|
||||||
client: Discord client
|
client: Discord client
|
||||||
trigger_context: Optional context about what triggered the argument
|
trigger_context: Optional context about what triggered the argument.
|
||||||
|
If provided, doubles as the argument theme/topic.
|
||||||
|
If empty, a random topic is selected from the rotation pool.
|
||||||
starting_message: Optional message to use as the first message in the argument
|
starting_message: Optional message to use as the first message in the argument
|
||||||
(the opposite persona will respond to it)
|
(the opposite persona will respond to it)
|
||||||
"""
|
"""
|
||||||
@@ -886,10 +1191,26 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
|
|||||||
# Track conversation for arbiter judgment
|
# Track conversation for arbiter judgment
|
||||||
conversation_log = []
|
conversation_log = []
|
||||||
|
|
||||||
|
# Build full personality system prompts so both personas have their
|
||||||
|
# complete lore, mood, and personality during the argument — same richness
|
||||||
|
# they have when talking to users normally.
|
||||||
|
from utils.evil_mode import get_evil_system_prompt
|
||||||
|
from utils.context_manager import get_miku_system_prompt_compact
|
||||||
|
miku_system = get_miku_system_prompt_compact()
|
||||||
|
evil_system = get_evil_system_prompt()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Determine the argument theme: if the caller provided trigger_context,
|
||||||
|
# use it as the argument topic. Otherwise, pick a random one.
|
||||||
|
if trigger_context and trigger_context.strip():
|
||||||
|
argument_topic = trigger_context.strip()
|
||||||
|
logger.info(f"Using context as argument topic: '{argument_topic[:80]}...'")
|
||||||
|
else:
|
||||||
|
argument_topic = pick_argument_topic(channel_id)
|
||||||
|
|
||||||
# If no starting message, generate the initial interrupting message
|
# If no starting message, generate the initial interrupting message
|
||||||
if last_message is None:
|
if last_message is None:
|
||||||
init_prompt = get_argument_start_prompt(initiator, trigger_context)
|
init_prompt = get_argument_start_prompt(initiator, trigger_context, argument_topic)
|
||||||
|
|
||||||
# Use force_evil_context to avoid race condition with globals.EVIL_MODE
|
# Use force_evil_context to avoid race condition with globals.EVIL_MODE
|
||||||
initial_message = await query_llama(
|
initial_message = await query_llama(
|
||||||
@@ -989,6 +1310,47 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
|
|||||||
# Don't end, just continue to the next exchange
|
# Don't end, just continue to the next exchange
|
||||||
else:
|
else:
|
||||||
# Clear winner - generate final triumphant message
|
# Clear winner - generate final triumphant message
|
||||||
|
|
||||||
|
# PARTING SHOT: 20% chance the LOSER gets one final message
|
||||||
|
# before the winner's victory line. Adds dramatic tension.
|
||||||
|
loser = "miku" if winner == "evil" else "evil"
|
||||||
|
if random.random() < 0.2:
|
||||||
|
loser_prompt = f"""The argument is ending and you know you've lost.
|
||||||
|
The last thing said was: "{last_message}"
|
||||||
|
|
||||||
|
Write ONE short, bitter parting shot. You're not conceding gracefully — you're getting
|
||||||
|
the last jab in before the winner claims victory. Make it sting, but keep it to 1 sentence.
|
||||||
|
|
||||||
|
Your current mood is: {globals.EVIL_DM_MOOD if loser == 'evil' else globals.DM_MOOD}"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
loser_message = await query_llama(
|
||||||
|
user_prompt=loser_prompt,
|
||||||
|
user_id=argument_user_id,
|
||||||
|
guild_id=guild_id,
|
||||||
|
response_type="autonomous_general",
|
||||||
|
model=globals.EVIL_TEXT_MODEL if loser == "evil" else globals.TEXT_MODEL,
|
||||||
|
force_evil_context=(loser == "evil")
|
||||||
|
)
|
||||||
|
if loser_message and not loser_message.startswith("Error"):
|
||||||
|
avatar_urls = get_persona_avatar_urls()
|
||||||
|
if loser == "evil":
|
||||||
|
await webhooks["evil_miku"].send(
|
||||||
|
content=loser_message,
|
||||||
|
username=get_evil_miku_display_name(),
|
||||||
|
avatar_url=avatar_urls.get("evil_miku")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await webhooks["miku"].send(
|
||||||
|
content=loser_message,
|
||||||
|
username=get_miku_display_name(),
|
||||||
|
avatar_url=avatar_urls.get("miku")
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1.5) # Brief pause before winner's victory
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Parting shot failed: {e}")
|
||||||
|
|
||||||
|
# Winner's victory message
|
||||||
end_prompt = get_argument_end_prompt(winner, exchange_count)
|
end_prompt = get_argument_end_prompt(winner, exchange_count)
|
||||||
|
|
||||||
# Add last message as context
|
# Add last message as context
|
||||||
@@ -1034,8 +1396,8 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
|
|||||||
|
|
||||||
# Clean up argument conversation history
|
# Clean up argument conversation history
|
||||||
try:
|
try:
|
||||||
conversation_history.clear_history(argument_user_id)
|
conversation_history.clear_channel(argument_user_id)
|
||||||
except:
|
except Exception:
|
||||||
pass # History cleanup is not critical
|
pass # History cleanup is not critical
|
||||||
|
|
||||||
end_argument(channel_id)
|
end_argument(channel_id)
|
||||||
@@ -1045,11 +1407,18 @@ async def run_argument(channel: discord.TextChannel, client, trigger_context: st
|
|||||||
# Get current speaker
|
# Get current speaker
|
||||||
current_speaker = globals.BIPOLAR_ARGUMENT_IN_PROGRESS.get(channel_id, {}).get("current_speaker", "evil")
|
current_speaker = globals.BIPOLAR_ARGUMENT_IN_PROGRESS.get(channel_id, {}).get("current_speaker", "evil")
|
||||||
|
|
||||||
|
# Build argument history from the last 6 exchanges so each persona
|
||||||
|
# knows what's already been said and doesn't repeat themselves
|
||||||
|
history_entries = conversation_log[-6:] if len(conversation_log) > 1 else []
|
||||||
|
arg_history = "\n".join(
|
||||||
|
f"{entry['speaker']}: {entry['message']}" for entry in history_entries
|
||||||
|
) if history_entries else ""
|
||||||
|
|
||||||
# Generate response with context about what the other said
|
# Generate response with context about what the other said
|
||||||
if current_speaker == "evil":
|
if current_speaker == "evil":
|
||||||
response_prompt = get_evil_argument_prompt(last_message, is_first_response=is_first_response)
|
response_prompt = get_evil_argument_prompt(last_message, is_first_response=is_first_response, argument_history=arg_history, argument_topic=argument_topic, system_prompt=evil_system)
|
||||||
else:
|
else:
|
||||||
response_prompt = get_miku_argument_prompt(last_message, is_first_response=is_first_response)
|
response_prompt = get_miku_argument_prompt(last_message, is_first_response=is_first_response, argument_history=arg_history, argument_topic=argument_topic, system_prompt=miku_system)
|
||||||
|
|
||||||
# Use force_evil_context to avoid race condition with globals.EVIL_MODE
|
# Use force_evil_context to avoid race condition with globals.EVIL_MODE
|
||||||
response = await query_llama(
|
response = await query_llama(
|
||||||
@@ -1141,7 +1510,7 @@ async def force_trigger_argument(channel: discord.TextChannel, client, context:
|
|||||||
Args:
|
Args:
|
||||||
channel: The Discord channel
|
channel: The Discord channel
|
||||||
client: Discord client
|
client: Discord client
|
||||||
context: Optional context string
|
context: Optional context string — doubles as the argument theme
|
||||||
starting_message: Optional message to use as the first message in the argument
|
starting_message: Optional message to use as the first message in the argument
|
||||||
"""
|
"""
|
||||||
if not globals.BIPOLAR_MODE:
|
if not globals.BIPOLAR_MODE:
|
||||||
|
|||||||
@@ -58,6 +58,99 @@ def _get_japanese_instruction() -> str:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_miku_system_prompt(mood_name: str = None, mood_description: str = None) -> str:
|
||||||
|
"""Returns the full Hatsune Miku system prompt with lore, lyrics, and mood.
|
||||||
|
Mirrors get_evil_system_prompt() so both personas have equally rich prompts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mood_name: Current mood name (defaults to globals.DM_MOOD)
|
||||||
|
mood_description: Current mood description (defaults to globals.DM_MOOD_DESCRIPTION)
|
||||||
|
"""
|
||||||
|
if mood_name is None:
|
||||||
|
mood_name = globals.DM_MOOD
|
||||||
|
if mood_description is None:
|
||||||
|
mood_description = globals.DM_MOOD_DESCRIPTION
|
||||||
|
|
||||||
|
lore = get_original_miku_lore()
|
||||||
|
prompt = get_original_miku_prompt()
|
||||||
|
lyrics = get_original_miku_lyrics()
|
||||||
|
|
||||||
|
return f"""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 - 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
|
||||||
|
|
||||||
|
YOUR CURRENT STATE — {mood_name.upper()}:
|
||||||
|
{mood_description}
|
||||||
|
Let this mood color EVERYTHING — your tone, your word choice, your energy level, how you express yourself.
|
||||||
|
|
||||||
|
You ARE Miku. Act like it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MIKU LORE (Complete Original)
|
||||||
|
{lore}
|
||||||
|
|
||||||
|
## MIKU PERSONALITY & GUIDELINES (Complete Original)
|
||||||
|
{prompt}
|
||||||
|
|
||||||
|
## MIKU SONG LYRICS (Complete Original)
|
||||||
|
{lyrics}"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_miku_system_prompt_compact(mood_name: str = None, mood_description: str = None) -> str:
|
||||||
|
"""Compact version for argument/dialogue use — same personality, shorter.
|
||||||
|
Keeps the critical rules, mood, and lore but omits lyrics to save tokens.
|
||||||
|
"""
|
||||||
|
if mood_name is None:
|
||||||
|
mood_name = globals.DM_MOOD
|
||||||
|
if mood_description is None:
|
||||||
|
mood_description = globals.DM_MOOD_DESCRIPTION
|
||||||
|
|
||||||
|
lore = get_original_miku_lore()
|
||||||
|
prompt = get_original_miku_prompt()
|
||||||
|
|
||||||
|
return f"""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
|
||||||
|
- NEVER preface responses with "Miku:" or similar labels
|
||||||
|
- Respond naturally and directly as Miku would
|
||||||
|
- Keep responses concise (2-3 sentences typically)
|
||||||
|
|
||||||
|
YOUR CURRENT STATE — {mood_name.upper()}:
|
||||||
|
{mood_description}
|
||||||
|
|
||||||
|
You ARE Miku. Act like it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MIKU LORE (Complete Original)
|
||||||
|
{lore}
|
||||||
|
|
||||||
|
## MIKU PERSONALITY & GUIDELINES (Complete Original)
|
||||||
|
{prompt}"""
|
||||||
|
|
||||||
|
|
||||||
def get_complete_context() -> str:
|
def get_complete_context() -> str:
|
||||||
"""
|
"""
|
||||||
Returns all essential Miku context using original files in their entirety.
|
Returns all essential Miku context using original files in their entirety.
|
||||||
|
|||||||
@@ -659,6 +659,13 @@ async def apply_evil_mode_changes(client, change_username=True, change_pfp=True,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to switch Cat to evil personality: {e}")
|
logger.error(f"Failed to switch Cat to evil personality: {e}")
|
||||||
|
|
||||||
|
# Update Discord presence to show evil mood activity
|
||||||
|
try:
|
||||||
|
from utils.activities import update_bot_presence
|
||||||
|
await update_bot_presence(globals.EVIL_DM_MOOD, is_evil=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update presence after enabling evil mode: {e}")
|
||||||
|
|
||||||
logger.info("Evil Mode enabled!")
|
logger.info("Evil Mode enabled!")
|
||||||
|
|
||||||
|
|
||||||
@@ -739,6 +746,13 @@ async def revert_evil_mode_changes(client, change_username=True, change_pfp=True
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to switch Cat to normal personality: {e}")
|
logger.error(f"Failed to switch Cat to normal personality: {e}")
|
||||||
|
|
||||||
|
# Restore Discord presence to normal mood activity
|
||||||
|
try:
|
||||||
|
from utils.activities import update_bot_presence
|
||||||
|
await update_bot_presence(globals.DM_MOOD, is_evil=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to restore presence after disabling evil mode: {e}")
|
||||||
|
|
||||||
logger.info("Evil Mode disabled!")
|
logger.info("Evil Mode disabled!")
|
||||||
|
|
||||||
|
|
||||||
@@ -894,6 +908,13 @@ async def rotate_evil_mood():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update nicknames after evil mood rotation: {e}")
|
logger.error(f"Failed to update nicknames after evil mood rotation: {e}")
|
||||||
|
|
||||||
|
# Update Discord presence to match new evil mood
|
||||||
|
try:
|
||||||
|
from utils.activities import update_bot_presence
|
||||||
|
await update_bot_presence(new_mood, is_evil=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update presence after evil mood rotation: {e}")
|
||||||
|
|
||||||
logger.info(f"Evil mood rotated from {old_mood} to {new_mood}")
|
logger.info(f"Evil mood rotated from {old_mood} to {new_mood}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from datetime import datetime
|
|||||||
from typing import List, Dict, Any, Tuple
|
from typing import List, Dict, Any, Tuple
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
import globals
|
|
||||||
|
|
||||||
|
from utils.conversation_history import conversation_history
|
||||||
from utils.twitter_fetcher import fetch_figurine_tweets_latest
|
from utils.twitter_fetcher import fetch_figurine_tweets_latest
|
||||||
from utils.image_handling import analyze_image_with_qwen, download_and_encode_image
|
from utils.image_handling import analyze_image_with_qwen, download_and_encode_image
|
||||||
from utils.llm import query_llama
|
from utils.llm import query_llama
|
||||||
@@ -204,15 +204,11 @@ async def send_figurine_dm_to_user(client: discord.Client, user_id: int, tweet:
|
|||||||
# Log the comment message
|
# Log the comment message
|
||||||
dm_logger.log_user_message(user, comment_message, is_bot_message=True)
|
dm_logger.log_user_message(user, comment_message, is_bot_message=True)
|
||||||
|
|
||||||
# IMPORTANT: Also add to globals.conversation_history for LLM context
|
# Add to conversation history for LLM context (uses centralized ConversationHistory)
|
||||||
user_id_str = str(user_id)
|
user_id_str = str(user_id)
|
||||||
|
|
||||||
# Add the tweet URL as a "system message" about what Miku just sent (use original URL for context)
|
|
||||||
tweet_context = f"[I just sent you this figurine tweet: {tweet_url}]"
|
tweet_context = f"[I just sent you this figurine tweet: {tweet_url}]"
|
||||||
|
conversation_history.add_message(channel_id=user_id_str, author_name="Miku", content=tweet_context, is_bot=True)
|
||||||
# Add the figurine comment to conversation history
|
conversation_history.add_message(channel_id=user_id_str, author_name="Miku", content=miku_comment, is_bot=True)
|
||||||
# Use empty user prompt since this was initiated by Miku
|
|
||||||
globals.conversation_history.setdefault(user_id_str, []).append((tweet_context, miku_comment))
|
|
||||||
|
|
||||||
logger.debug(f"Messages logged to both DM history and conversation context for user {user_id}")
|
logger.debug(f"Messages logged to both DM history and conversation context for user {user_id}")
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ MODEL_TO_GPU = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
PREFER_AMD_GPU = os.getenv("PREFER_AMD_GPU", "false").lower() == "true"
|
# PREFER_AMD_GPU lives in globals so the config API can update it at runtime.
|
||||||
|
# We read globals.PREFER_AMD_GPU in functions below instead of a frozen local.
|
||||||
AMD_MODELS_ENABLED = os.getenv("AMD_MODELS_ENABLED", "true").lower() == "true"
|
AMD_MODELS_ENABLED = os.getenv("AMD_MODELS_ENABLED", "true").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ def get_llama_url_with_load_balancing(
|
|||||||
return globals.LLAMA_URL, "llama3.1"
|
return globals.LLAMA_URL, "llama3.1"
|
||||||
|
|
||||||
# AMD enabled - implement load balancing
|
# AMD enabled - implement load balancing
|
||||||
use_amd = prefer_amd or PREFER_AMD_GPU or (random.random() < 0.5)
|
use_amd = prefer_amd or globals.PREFER_AMD_GPU or (random.random() < 0.5)
|
||||||
|
|
||||||
if task_type == "evil":
|
if task_type == "evil":
|
||||||
# Evil/uncensored models
|
# Evil/uncensored models
|
||||||
|
|||||||
@@ -418,14 +418,13 @@ async def rephrase_as_miku(vision_output, user_prompt, guild_id=None, user_id=No
|
|||||||
|
|
||||||
# Format the user's message to include vision context with media type
|
# Format the user's message to include vision context with media type
|
||||||
# This will be saved to history automatically by query_llama
|
# This will be saved to history automatically by query_llama
|
||||||
if media_type == "gif":
|
_MEDIA_PREFIXES = {
|
||||||
media_prefix = "Looking at a GIF"
|
"gif": "Looking at a GIF",
|
||||||
elif media_type == "tenor_gif":
|
"tenor_gif": "Looking at a Tenor GIF",
|
||||||
media_prefix = "Looking at a Tenor GIF"
|
"video": "Looking at a video",
|
||||||
elif media_type == "video":
|
"rich_embed": "Looking at embedded content",
|
||||||
media_prefix = "Looking at a video"
|
}
|
||||||
else: # image
|
media_prefix = _MEDIA_PREFIXES.get(media_type, "Looking at an image")
|
||||||
media_prefix = "Looking at an image"
|
|
||||||
|
|
||||||
if user_prompt:
|
if user_prompt:
|
||||||
# Include media type, vision description, and user's text
|
# Include media type, vision description, and user's text
|
||||||
@@ -473,15 +472,22 @@ async def rephrase_as_miku(vision_output, user_prompt, guild_id=None, user_id=No
|
|||||||
if globals.EVIL_MODE:
|
if globals.EVIL_MODE:
|
||||||
effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}"
|
effective_mood = f"EVIL:{getattr(globals, 'EVIL_DM_MOOD', 'evil_neutral')}"
|
||||||
logger.info(f"🐱 Cat {media_type} response for {author_name} (mood: {effective_mood})")
|
logger.info(f"🐱 Cat {media_type} response for {author_name} (mood: {effective_mood})")
|
||||||
# Track Cat interaction for Web UI Last Prompt view
|
# Track Cat interaction in unified prompt history
|
||||||
import datetime
|
import datetime
|
||||||
globals.LAST_CAT_INTERACTION = {
|
globals._prompt_id_counter += 1
|
||||||
|
globals.PROMPT_HISTORY.append({
|
||||||
|
"id": globals._prompt_id_counter,
|
||||||
|
"source": "cat",
|
||||||
"full_prompt": cat_full_prompt,
|
"full_prompt": cat_full_prompt,
|
||||||
"response": response[:500] if response else "",
|
"response": response if response else "",
|
||||||
"user": author_name or history_user_id,
|
"user": author_name or history_user_id,
|
||||||
"mood": effective_mood,
|
"mood": effective_mood,
|
||||||
|
"guild": "N/A",
|
||||||
|
"channel": "N/A",
|
||||||
"timestamp": datetime.datetime.now().isoformat(),
|
"timestamp": datetime.datetime.now().isoformat(),
|
||||||
}
|
"model": "Cat LLM",
|
||||||
|
"response_type": response_type,
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"🐱 Cat {media_type} pipeline error, falling back to query_llama: {e}")
|
logger.warning(f"🐱 Cat {media_type} pipeline error, falling back to query_llama: {e}")
|
||||||
response = None
|
response = None
|
||||||
@@ -503,6 +509,330 @@ async def rephrase_as_miku(vision_output, user_prompt, guild_id=None, user_id=No
|
|||||||
analyze_image_with_qwen = analyze_image_with_vision
|
analyze_image_with_qwen = analyze_image_with_vision
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared tail helper — send response, log DM, check bipolar interjection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _send_log_bipolar(message, reply_text, is_dm, *, media_label=""):
|
||||||
|
"""
|
||||||
|
Common tail shared by every media handler *and* the text-fallback path in
|
||||||
|
bot.py. Sends *reply_text* to the channel, logs the reply in the DM
|
||||||
|
ledger when appropriate, and fires a bipolar-interjection check for server
|
||||||
|
messages.
|
||||||
|
|
||||||
|
Returns the sent ``discord.Message`` so callers can use it if needed.
|
||||||
|
"""
|
||||||
|
from utils.dm_logger import dm_logger
|
||||||
|
from utils.task_tracker import create_tracked_task
|
||||||
|
|
||||||
|
label = f" {media_label}" if media_label else ""
|
||||||
|
if is_dm:
|
||||||
|
logger.info(
|
||||||
|
f"💌 DM{label} response to {message.author.display_name} "
|
||||||
|
f"(using DM mood: {globals.DM_MOOD})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
guild_name = message.guild.name if message.guild else "unknown"
|
||||||
|
logger.info(
|
||||||
|
f"💬 Server{label} response to {message.author.display_name} "
|
||||||
|
f"in {guild_name} (using server mood)"
|
||||||
|
)
|
||||||
|
|
||||||
|
response_message = await message.channel.send(reply_text)
|
||||||
|
|
||||||
|
# Log bot's reply in the DM ledger
|
||||||
|
if is_dm:
|
||||||
|
dm_logger.log_user_message(message.author, response_message, is_bot_message=True)
|
||||||
|
|
||||||
|
# Bipolar-mode interjection check (server messages only)
|
||||||
|
if not is_dm and globals.BIPOLAR_MODE:
|
||||||
|
try:
|
||||||
|
from utils.persona_dialogue import check_for_interjection
|
||||||
|
current_persona = "evil" if globals.EVIL_MODE else "miku"
|
||||||
|
create_tracked_task(
|
||||||
|
check_for_interjection(response_message, current_persona),
|
||||||
|
task_name="interjection_check",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking for persona interjection: {e}")
|
||||||
|
|
||||||
|
return response_message
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# High-level media dispatcher — called from bot.py on_message()
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def process_media_in_message(message, prompt, is_dm, guild_id) -> bool:
|
||||||
|
"""
|
||||||
|
Inspect *message* for image/video/GIF attachments and embeds.
|
||||||
|
|
||||||
|
If any media is found and successfully processed, a reply is sent to the
|
||||||
|
channel and this function returns ``True``. Otherwise it returns
|
||||||
|
``False`` so the caller can fall through to text-only handling.
|
||||||
|
"""
|
||||||
|
author_id = str(message.author.id)
|
||||||
|
author_name = message.author.display_name
|
||||||
|
|
||||||
|
# ---- 1. Image attachments (.jpg, .jpeg, .png, .webp) -----------------
|
||||||
|
if message.attachments:
|
||||||
|
for attachment in message.attachments:
|
||||||
|
lower = attachment.filename.lower()
|
||||||
|
|
||||||
|
if any(lower.endswith(ext) for ext in (".jpg", ".jpeg", ".png", ".webp")):
|
||||||
|
base64_img = await download_and_encode_image(attachment.url)
|
||||||
|
if not base64_img:
|
||||||
|
await message.channel.send("I couldn't load the image, sorry!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
qwen_description = await analyze_image_with_vision(base64_img, user_prompt=prompt)
|
||||||
|
if not qwen_description or not qwen_description.strip():
|
||||||
|
await message.channel.send(
|
||||||
|
"I couldn't see that image clearly, sorry! Try sending it again."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
miku_reply = await rephrase_as_miku(
|
||||||
|
qwen_description, prompt,
|
||||||
|
guild_id=guild_id,
|
||||||
|
user_id=author_id,
|
||||||
|
author_name=author_name,
|
||||||
|
media_type="image",
|
||||||
|
)
|
||||||
|
await _send_log_bipolar(message, miku_reply, is_dm, media_label="image")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---- 2. Video / GIF attachments (.gif, .mp4, .webm, .mov) ----
|
||||||
|
elif any(lower.endswith(ext) for ext in (".gif", ".mp4", ".webm", ".mov")):
|
||||||
|
is_gif = lower.endswith(".gif")
|
||||||
|
media_type = "gif" if is_gif else "video"
|
||||||
|
|
||||||
|
logger.debug(f"🎬 Processing {media_type}: {attachment.filename}")
|
||||||
|
|
||||||
|
media_bytes_b64 = await download_and_encode_media(attachment.url)
|
||||||
|
if not media_bytes_b64:
|
||||||
|
await message.channel.send(f"I couldn't load the {media_type}, sorry!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
media_bytes = base64.b64decode(media_bytes_b64)
|
||||||
|
|
||||||
|
if is_gif:
|
||||||
|
logger.debug("🔄 Converting GIF to MP4 for processing...")
|
||||||
|
mp4_bytes = await convert_gif_to_mp4(media_bytes)
|
||||||
|
if mp4_bytes:
|
||||||
|
media_bytes = mp4_bytes
|
||||||
|
logger.info("✅ GIF converted to MP4")
|
||||||
|
else:
|
||||||
|
logger.warning("GIF conversion failed, trying direct processing")
|
||||||
|
|
||||||
|
frames = await extract_video_frames(media_bytes, num_frames=6)
|
||||||
|
if not frames:
|
||||||
|
await message.channel.send(
|
||||||
|
f"I couldn't extract frames from that {media_type}, sorry!"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"📹 Extracted {len(frames)} frames from {attachment.filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
video_description = await analyze_video_with_vision(
|
||||||
|
frames, media_type=media_type, user_prompt=prompt,
|
||||||
|
)
|
||||||
|
if not video_description or not video_description.strip():
|
||||||
|
await message.channel.send(
|
||||||
|
f"I couldn't analyze that {media_type} clearly, sorry! "
|
||||||
|
"Try sending it again."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
miku_reply = await rephrase_as_miku(
|
||||||
|
video_description, prompt,
|
||||||
|
guild_id=guild_id,
|
||||||
|
user_id=author_id,
|
||||||
|
author_name=author_name,
|
||||||
|
media_type=media_type,
|
||||||
|
)
|
||||||
|
await _send_log_bipolar(message, miku_reply, is_dm, media_label=media_type)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---- 3. Tenor GIF embeds (gifv from tenor.com) -----------------------
|
||||||
|
if message.embeds:
|
||||||
|
for embed in message.embeds:
|
||||||
|
if embed.type == "gifv" and embed.url and "tenor.com" in embed.url:
|
||||||
|
logger.info(f"🎭 Processing Tenor GIF from embed: {embed.url}")
|
||||||
|
|
||||||
|
gif_url = await extract_tenor_gif_url(embed.url)
|
||||||
|
if not gif_url:
|
||||||
|
if hasattr(embed, "video") and embed.video:
|
||||||
|
gif_url = embed.video.url
|
||||||
|
elif hasattr(embed, "thumbnail") and embed.thumbnail:
|
||||||
|
gif_url = embed.thumbnail.url
|
||||||
|
|
||||||
|
if not gif_url:
|
||||||
|
logger.warning("Could not extract GIF URL from Tenor embed")
|
||||||
|
continue
|
||||||
|
|
||||||
|
media_bytes_b64 = await download_and_encode_media(gif_url)
|
||||||
|
if not media_bytes_b64:
|
||||||
|
await message.channel.send(
|
||||||
|
"I couldn't load that Tenor GIF, sorry!"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
media_bytes = base64.b64decode(media_bytes_b64)
|
||||||
|
|
||||||
|
logger.debug("Converting Tenor GIF to MP4 for processing...")
|
||||||
|
mp4_bytes = await convert_gif_to_mp4(media_bytes)
|
||||||
|
if not mp4_bytes:
|
||||||
|
logger.warning(
|
||||||
|
"GIF conversion failed, trying direct frame extraction"
|
||||||
|
)
|
||||||
|
mp4_bytes = media_bytes
|
||||||
|
else:
|
||||||
|
logger.debug("Tenor GIF converted to MP4")
|
||||||
|
|
||||||
|
frames = await extract_video_frames(mp4_bytes, num_frames=6)
|
||||||
|
if not frames:
|
||||||
|
await message.channel.send(
|
||||||
|
"I couldn't extract frames from that GIF, sorry!"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"📹 Extracted {len(frames)} frames from Tenor GIF"
|
||||||
|
)
|
||||||
|
|
||||||
|
video_description = await analyze_video_with_vision(
|
||||||
|
frames, media_type="tenor_gif", user_prompt=prompt,
|
||||||
|
)
|
||||||
|
if not video_description or not video_description.strip():
|
||||||
|
await message.channel.send(
|
||||||
|
"I couldn't analyze that GIF clearly, sorry! "
|
||||||
|
"Try sending it again."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
miku_reply = await rephrase_as_miku(
|
||||||
|
video_description, prompt,
|
||||||
|
guild_id=guild_id,
|
||||||
|
user_id=author_id,
|
||||||
|
author_name=author_name,
|
||||||
|
media_type="tenor_gif",
|
||||||
|
)
|
||||||
|
await _send_log_bipolar(
|
||||||
|
message, miku_reply, is_dm, media_label="Tenor GIF",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ---- 4. Rich / article / image / video / link embeds ---------
|
||||||
|
elif embed.type in ("rich", "article", "image", "video", "link"):
|
||||||
|
logger.info(f"Processing {embed.type} embed")
|
||||||
|
|
||||||
|
embed_content = await extract_embed_content(embed)
|
||||||
|
if not embed_content["has_content"]:
|
||||||
|
logger.warning("Embed has no extractable content, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
embed_context_parts = []
|
||||||
|
if embed_content["text"]:
|
||||||
|
truncated = embed_content["text"][:500]
|
||||||
|
if len(embed_content["text"]) > 500:
|
||||||
|
truncated += "..."
|
||||||
|
embed_context_parts.append(
|
||||||
|
f"[Embedded content: {truncated}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Analyze images found inside the embed
|
||||||
|
for img_url in embed_content["images"]:
|
||||||
|
logger.info(f"Processing image from embed: {img_url}")
|
||||||
|
try:
|
||||||
|
base64_img = await download_and_encode_image(img_url)
|
||||||
|
if base64_img:
|
||||||
|
logger.info(
|
||||||
|
"Image downloaded, analyzing with vision model..."
|
||||||
|
)
|
||||||
|
qwen_description = await analyze_image_with_vision(
|
||||||
|
base64_img, user_prompt=prompt,
|
||||||
|
)
|
||||||
|
if qwen_description and qwen_description.strip():
|
||||||
|
embed_context_parts.append(
|
||||||
|
f"[Embedded image shows: {qwen_description}]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error("Failed to download image from embed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing embedded image: {e}")
|
||||||
|
|
||||||
|
# Analyze videos found inside the embed
|
||||||
|
for video_url in embed_content["videos"]:
|
||||||
|
logger.info(
|
||||||
|
f"🎬 Processing video from embed: {video_url}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
media_bytes_b64 = await download_and_encode_media(
|
||||||
|
video_url,
|
||||||
|
)
|
||||||
|
if media_bytes_b64:
|
||||||
|
media_bytes = base64.b64decode(media_bytes_b64)
|
||||||
|
frames = await extract_video_frames(
|
||||||
|
media_bytes, num_frames=6,
|
||||||
|
)
|
||||||
|
if frames:
|
||||||
|
logger.info(
|
||||||
|
f"📹 Extracted {len(frames)} frames, "
|
||||||
|
"analyzing with vision model..."
|
||||||
|
)
|
||||||
|
video_description = (
|
||||||
|
await analyze_video_with_vision(
|
||||||
|
frames,
|
||||||
|
media_type="video",
|
||||||
|
user_prompt=prompt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
video_description
|
||||||
|
and video_description.strip()
|
||||||
|
):
|
||||||
|
embed_context_parts.append(
|
||||||
|
f"[Embedded video shows: "
|
||||||
|
f"{video_description}]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Failed to extract frames from video"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Failed to download video from embed"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error processing embedded video: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not embed_context_parts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build a combined vision description and route through
|
||||||
|
# rephrase_as_miku (which handles Cat → LLM fallback,
|
||||||
|
# mood resolution, and prompt history tracking).
|
||||||
|
combined_description = "\n".join(embed_context_parts)
|
||||||
|
miku_reply = await rephrase_as_miku(
|
||||||
|
combined_description, prompt,
|
||||||
|
guild_id=guild_id,
|
||||||
|
user_id=author_id,
|
||||||
|
author_name=author_name,
|
||||||
|
media_type="rich_embed",
|
||||||
|
)
|
||||||
|
await _send_log_bipolar(
|
||||||
|
message, miku_reply, is_dm, media_label="embed",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def extract_embed_content(embed):
|
async def extract_embed_content(embed):
|
||||||
"""
|
"""
|
||||||
Extract text and media content from a Discord embed.
|
Extract text and media content from a Discord embed.
|
||||||
|
|||||||
@@ -381,7 +381,23 @@ Please respond in a way that reflects this emotional tone.{pfp_context}"""
|
|||||||
media_note = media_descriptions.get(media_type, f"The user has sent you {media_type}.")
|
media_note = media_descriptions.get(media_type, f"The user has sent you {media_type}.")
|
||||||
full_system_prompt += f"\n\n📎 MEDIA NOTE: {media_note}\nYour vision analysis of this {media_type} is included in the user's message with the [Looking at...] prefix."
|
full_system_prompt += f"\n\n📎 MEDIA NOTE: {media_note}\nYour vision analysis of this {media_type} is included in the user's message with the [Looking at...] prefix."
|
||||||
|
|
||||||
globals.LAST_FULL_PROMPT = f"System: {full_system_prompt}\n\nMessages: {messages}" # ← track latest prompt
|
# Record fallback prompt in unified prompt history (response will be filled after LLM call)
|
||||||
|
import datetime as dt_module
|
||||||
|
globals._prompt_id_counter += 1
|
||||||
|
prompt_entry = {
|
||||||
|
"id": globals._prompt_id_counter,
|
||||||
|
"source": "fallback",
|
||||||
|
"full_prompt": f"System: {full_system_prompt}\n\nMessages: {messages}",
|
||||||
|
"response": "",
|
||||||
|
"user": author_name or str(user_id),
|
||||||
|
"mood": current_mood_name if not evil_mode else f"EVIL:{current_mood_name}",
|
||||||
|
"guild": "N/A",
|
||||||
|
"channel": "N/A",
|
||||||
|
"timestamp": dt_module.datetime.now().isoformat(),
|
||||||
|
"model": model,
|
||||||
|
"response_type": response_type,
|
||||||
|
}
|
||||||
|
globals.PROMPT_HISTORY.append(prompt_entry)
|
||||||
|
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
@@ -475,9 +491,8 @@ Please respond in a way that reflects this emotional tone.{pfp_context}"""
|
|||||||
is_bot=True
|
is_bot=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Also save to legacy globals for backward compatibility (skip error messages)
|
# Update the prompt history entry with the actual response
|
||||||
if user_prompt and user_prompt.strip() and reply and reply.strip() and reply != "Someone tell Koko-nii there is a problem with my AI.":
|
prompt_entry["response"] = reply if reply else ""
|
||||||
globals.conversation_history[user_id].append((user_prompt, reply))
|
|
||||||
|
|
||||||
return reply
|
return reply
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ COMPONENTS = {
|
|||||||
'error_handler': 'Error detection and webhook notifications',
|
'error_handler': 'Error detection and webhook notifications',
|
||||||
'uno': 'UNO game automation and commands',
|
'uno': 'UNO game automation and commands',
|
||||||
'task_tracker': 'Task tracking and management system',
|
'task_tracker': 'Task tracking and management system',
|
||||||
|
'activity': 'Mood-based Discord presence and activity status',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Global configuration
|
# Global configuration
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ async def rotate_dm_mood():
|
|||||||
globals.DM_MOOD = new_mood
|
globals.DM_MOOD = new_mood
|
||||||
globals.DM_MOOD_DESCRIPTION = load_mood_description(new_mood)
|
globals.DM_MOOD_DESCRIPTION = load_mood_description(new_mood)
|
||||||
|
|
||||||
|
# Update Discord presence to match new mood
|
||||||
|
try:
|
||||||
|
from utils.activities import update_bot_presence
|
||||||
|
await update_bot_presence(new_mood, is_evil=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update presence after DM mood rotation: {e}")
|
||||||
|
|
||||||
logger.info(f"DM mood rotated from {old_mood} to {new_mood}")
|
logger.info(f"DM mood rotated from {old_mood} to {new_mood}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -307,6 +314,13 @@ async def rotate_server_mood(guild_id: int):
|
|||||||
# Update nickname for this specific server
|
# Update nickname for this specific server
|
||||||
await update_server_nickname(guild_id)
|
await update_server_nickname(guild_id)
|
||||||
|
|
||||||
|
# Update Discord presence to match new mood
|
||||||
|
try:
|
||||||
|
from utils.activities import update_bot_presence
|
||||||
|
await update_bot_presence(new_mood_name, is_evil=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update presence after server mood rotation: {e}")
|
||||||
|
|
||||||
logger.info(f"Rotated mood for server {guild_id} from {old_mood_name} to {new_mood_name}")
|
logger.info(f"Rotated mood for server {guild_id} from {old_mood_name} to {new_mood_name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Exception in rotate_server_mood for server {guild_id}: {e}")
|
logger.error(f"Exception in rotate_server_mood for server {guild_id}: {e}")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ logger = get_logger('persona')
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from transformers import pipeline
|
import re
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CONSTANTS
|
# CONSTANTS
|
||||||
@@ -40,10 +40,15 @@ DIALOGUE_TIMEOUT = 900 # 15 minutes max dialogue duration
|
|||||||
ARGUMENT_TENSION_THRESHOLD = 0.75 # Tension level that triggers argument escalation
|
ARGUMENT_TENSION_THRESHOLD = 0.75 # Tension level that triggers argument escalation
|
||||||
|
|
||||||
# Initial trigger settings
|
# Initial trigger settings
|
||||||
INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block
|
INTERJECTION_COOLDOWN_HARD = 180 # 3 minutes hard block PER CHANNEL
|
||||||
INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery
|
INTERJECTION_COOLDOWN_SOFT = 900 # 15 minutes for full recovery PER CHANNEL
|
||||||
INTERJECTION_THRESHOLD = 0.5 # Score needed to trigger interjection
|
INTERJECTION_THRESHOLD = 0.5 # Score needed to trigger interjection
|
||||||
|
|
||||||
|
# Conversation streak: if score is close but below threshold N times in a row,
|
||||||
|
# force a dialogue trigger (catches extended conversations building toward something)
|
||||||
|
STREAK_THRESHOLD = 3 # Number of near-miss messages before force trigger
|
||||||
|
STREAK_MIN_SCORE = 0.3 # Minimum score to count as a "near miss"
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# INTERJECTION SCORER (Initial Trigger Decision)
|
# INTERJECTION SCORER (Initial Trigger Decision)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -51,32 +56,49 @@ INTERJECTION_THRESHOLD = 0.5 # Score needed to trigger interjection
|
|||||||
class InterjectionScorer:
|
class InterjectionScorer:
|
||||||
"""
|
"""
|
||||||
Decides if the opposite persona should interject based on message content.
|
Decides if the opposite persona should interject based on message content.
|
||||||
Uses fast heuristics + sentiment analysis (no LLM calls).
|
Uses fast heuristics — no LLM calls, no heavy ML dependencies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
_sentiment_analyzer = None
|
|
||||||
|
# Simple sentiment word lists (no PyTorch/transformers needed)
|
||||||
|
_POSITIVE_WORDS = {"happy", "love", "wonderful", "amazing", "great", "beautiful", "sweet", "kind", "hope", "dream", "excited", "best", "grateful", "blessed", "joy", "perfect", "adorable", "precious", "delightful", "fantastic"}
|
||||||
|
_NEGATIVE_WORDS = {"hate", "terrible", "awful", "horrible", "disgusting", "pathetic", "worthless", "stupid", "idiot", "sad", "angry", "upset", "miserable", "worst", "ugly", "boring", "annoying", "frustrated", "cruel", "mean"}
|
||||||
|
|
||||||
def __new__(cls):
|
def __new__(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super().__new__(cls)
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._cooldowns = {} # Per-channel cooldown timestamps
|
||||||
|
cls._instance._streaks = {} # Per-channel near-miss streaks
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
@property
|
def _get_sentiment(self, text: str) -> tuple:
|
||||||
def sentiment_analyzer(self):
|
"""Lightweight heuristic sentiment analysis — returns (label, score).
|
||||||
"""Lazy load sentiment analyzer"""
|
No ML dependencies. Uses word counting + intensity markers.
|
||||||
if self._sentiment_analyzer is None:
|
|
||||||
logger.debug("Loading sentiment analyzer for persona dialogue...")
|
Returns:
|
||||||
try:
|
tuple: ('POSITIVE' or 'NEGATIVE', confidence 0.0-1.0)
|
||||||
self._sentiment_analyzer = pipeline(
|
"""
|
||||||
"sentiment-analysis",
|
text_lower = text.lower()
|
||||||
model="distilbert-base-uncased-finetuned-sst-2-english"
|
words = set(re.findall(r'\b\w+\b', text_lower))
|
||||||
)
|
|
||||||
logger.info("Sentiment analyzer loaded")
|
pos_count = len(words & self._POSITIVE_WORDS)
|
||||||
except Exception as e:
|
neg_count = len(words & self._NEGATIVE_WORDS)
|
||||||
logger.error(f"Failed to load sentiment analyzer: {e}")
|
|
||||||
self._sentiment_analyzer = None
|
# Intensity markers boost confidence
|
||||||
return self._sentiment_analyzer
|
exclamations = text.count('!')
|
||||||
|
caps_ratio = sum(1 for c in text if c.isupper()) / max(len(text), 1)
|
||||||
|
intensity_boost = min((exclamations * 0.1) + (caps_ratio * 0.3), 0.4)
|
||||||
|
|
||||||
|
if neg_count > pos_count:
|
||||||
|
confidence = min(0.5 + (neg_count * 0.15) + intensity_boost, 1.0)
|
||||||
|
return ('NEGATIVE', confidence)
|
||||||
|
elif pos_count > neg_count:
|
||||||
|
confidence = min(0.5 + (pos_count * 0.15) + intensity_boost, 1.0)
|
||||||
|
return ('POSITIVE', confidence)
|
||||||
|
else:
|
||||||
|
# Neutral — slight lean based on intensity
|
||||||
|
return ('POSITIVE', 0.5)
|
||||||
|
|
||||||
async def should_interject(self, message: discord.Message, current_persona: str) -> tuple:
|
async def should_interject(self, message: discord.Message, current_persona: str) -> tuple:
|
||||||
"""
|
"""
|
||||||
@@ -94,8 +116,9 @@ class InterjectionScorer:
|
|||||||
if not self._passes_basic_filter(message):
|
if not self._passes_basic_filter(message):
|
||||||
return False, "basic_filter_failed", 0.0
|
return False, "basic_filter_failed", 0.0
|
||||||
|
|
||||||
# Check cooldown
|
# Check per-channel cooldown
|
||||||
cooldown_mult = self._check_cooldown()
|
channel_id = message.channel.id
|
||||||
|
cooldown_mult = self._check_cooldown(channel_id)
|
||||||
if cooldown_mult == 0.0:
|
if cooldown_mult == 0.0:
|
||||||
return False, "cooldown_active", 0.0
|
return False, "cooldown_active", 0.0
|
||||||
|
|
||||||
@@ -146,10 +169,17 @@ class InterjectionScorer:
|
|||||||
# Apply cooldown multiplier
|
# Apply cooldown multiplier
|
||||||
score *= cooldown_mult
|
score *= cooldown_mult
|
||||||
|
|
||||||
|
# Check conversation streak (near-misses that build toward a trigger)
|
||||||
|
streak_triggered = self._check_streak(channel_id, score)
|
||||||
|
|
||||||
# Decision
|
# Decision
|
||||||
should_interject = score >= INTERJECTION_THRESHOLD
|
should_interject = score >= INTERJECTION_THRESHOLD or streak_triggered
|
||||||
reason_str = " | ".join(reasons) if reasons else "no_triggers"
|
reason_str = " | ".join(reasons) if reasons else "no_triggers"
|
||||||
|
|
||||||
|
if streak_triggered and not should_interject:
|
||||||
|
reason_str = "streak_force_trigger"
|
||||||
|
logger.info(f"[Interjection] Streak force trigger in channel {channel_id} (score: {score:.2f})")
|
||||||
|
|
||||||
if should_interject:
|
if should_interject:
|
||||||
logger.info(f"{opposite_persona.upper()} WILL INTERJECT (score: {score:.2f})")
|
logger.info(f"{opposite_persona.upper()} WILL INTERJECT (score: {score:.2f})")
|
||||||
logger.info(f" Reasons: {reason_str}")
|
logger.info(f" Reasons: {reason_str}")
|
||||||
@@ -198,18 +228,22 @@ class InterjectionScorer:
|
|||||||
if opposite_persona == "evil":
|
if opposite_persona == "evil":
|
||||||
# Things Evil Miku can't resist commenting on
|
# Things Evil Miku can't resist commenting on
|
||||||
TRIGGER_TOPICS = {
|
TRIGGER_TOPICS = {
|
||||||
"optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing"],
|
"optimism": ["happiness", "joy", "love", "kindness", "hope", "dreams", "wonderful", "amazing", "blessed", "grateful"],
|
||||||
"morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice"],
|
"morality": ["good", "should", "must", "right thing", "deserve", "fair", "justice", "the right", "better person"],
|
||||||
"weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know"],
|
"weakness": ["scared", "nervous", "worried", "unsure", "help me", "don't know", "confused", "lost", "lonely", "alone"],
|
||||||
"innocence": ["innocent", "pure", "sweet", "cute", "wholesome", "precious"],
|
"innocence": ["innocent", "pure", "sweet", "cute", "wholesome", "precious", "adorable"],
|
||||||
|
"enthusiasm": ["best day", "so excited", "can't wait", "so happy", "i love this", "this is great"],
|
||||||
|
"vulnerability": ["i think", "i feel", "maybe", "sometimes i wonder", "i wish", "i'm trying"],
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Things Miku can't ignore
|
# Things Miku can't ignore
|
||||||
TRIGGER_TOPICS = {
|
TRIGGER_TOPICS = {
|
||||||
"negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic"],
|
"negativity": ["hate", "terrible", "awful", "worst", "horrible", "disgusting", "pathetic", "ugly", "boring", "annoying"],
|
||||||
"cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool"],
|
"cruelty": ["deserve pain", "suffer", "worthless", "stupid", "idiot", "fool", "moron", "loser", "nobody"],
|
||||||
"hopelessness": ["no point", "meaningless", "nobody cares", "why bother", "give up"],
|
"hopelessness": ["no point", "meaningless", "nobody cares", "why bother", "give up", "what's the point", "don't care", "doesn't matter", "who cares"],
|
||||||
"evil_gloating": ["foolish", "naive", "weak", "inferior", "pathetic"],
|
"evil_gloating": ["foolish", "naive", "weak", "inferior", "pathetic", "beneath me", "waste of space"],
|
||||||
|
"provocation": ["fight me", "prove it", "make me", "i dare you", "try me", "you can't", "you won't"],
|
||||||
|
"dismissal": ["whatever", "shut up", "go away", "leave me alone", "not worth", "don't bother"],
|
||||||
}
|
}
|
||||||
|
|
||||||
total_matches = 0
|
total_matches = 0
|
||||||
@@ -217,28 +251,24 @@ class InterjectionScorer:
|
|||||||
matches = sum(1 for keyword in keywords if keyword in content_lower)
|
matches = sum(1 for keyword in keywords if keyword in content_lower)
|
||||||
total_matches += matches
|
total_matches += matches
|
||||||
|
|
||||||
return min(total_matches / 3.0, 1.0)
|
return min(total_matches / 2.0, 1.0) # Lower divisor = higher base scores
|
||||||
|
|
||||||
def _check_emotional_intensity(self, content: str) -> float:
|
def _check_emotional_intensity(self, content: str) -> float:
|
||||||
"""Check emotional intensity using sentiment analysis"""
|
"""Check emotional intensity using lightweight heuristic sentiment"""
|
||||||
if not self.sentiment_analyzer:
|
label, confidence = self._get_sentiment(content)
|
||||||
return 0.5 # Neutral if no analyzer
|
|
||||||
|
|
||||||
try:
|
# Punctuation intensity
|
||||||
result = self.sentiment_analyzer(content[:512])[0]
|
exclamations = content.count('!')
|
||||||
confidence = result['score']
|
questions = content.count('?')
|
||||||
|
caps_ratio = sum(1 for c in content if c.isupper()) / max(len(content), 1)
|
||||||
|
|
||||||
# Punctuation intensity
|
intensity_markers = (exclamations * 0.15) + (questions * 0.1) + (caps_ratio * 0.3)
|
||||||
exclamations = content.count('!')
|
|
||||||
questions = content.count('?')
|
|
||||||
caps_ratio = sum(1 for c in content if c.isupper()) / max(len(content), 1)
|
|
||||||
|
|
||||||
intensity_markers = (exclamations * 0.15) + (questions * 0.1) + (caps_ratio * 0.3)
|
# Negative content = higher emotional intensity for triggering purposes
|
||||||
|
if label == 'NEGATIVE':
|
||||||
return min(confidence * 0.6 + intensity_markers, 1.0)
|
return min(confidence * 0.7 + intensity_markers, 1.0)
|
||||||
except Exception as e:
|
else:
|
||||||
logger.error(f"Sentiment analysis error: {e}")
|
return min(confidence * 0.4 + intensity_markers, 1.0)
|
||||||
return 0.5
|
|
||||||
|
|
||||||
def _detect_personality_clash(self, content: str, opposite_persona: str) -> float:
|
def _detect_personality_clash(self, content: str, opposite_persona: str) -> float:
|
||||||
"""Detect statements that clash with the opposite persona's values"""
|
"""Detect statements that clash with the opposite persona's values"""
|
||||||
@@ -300,13 +330,11 @@ class InterjectionScorer:
|
|||||||
|
|
||||||
return min(score, 1.0)
|
return min(score, 1.0)
|
||||||
|
|
||||||
def _check_cooldown(self) -> float:
|
def _check_cooldown(self, channel_id: int) -> float:
|
||||||
"""Check cooldown and return multiplier (0.0 = blocked, 1.0 = full)"""
|
"""Check per-channel cooldown and return multiplier (0.0 = blocked, 1.0 = full)"""
|
||||||
if not hasattr(globals, 'LAST_PERSONA_DIALOGUE_TIME'):
|
|
||||||
globals.LAST_PERSONA_DIALOGUE_TIME = 0
|
|
||||||
|
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
time_since_last = current_time - globals.LAST_PERSONA_DIALOGUE_TIME
|
last_time = self._cooldowns.get(channel_id, 0)
|
||||||
|
time_since_last = current_time - last_time
|
||||||
|
|
||||||
if time_since_last < INTERJECTION_COOLDOWN_HARD:
|
if time_since_last < INTERJECTION_COOLDOWN_HARD:
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -315,6 +343,35 @@ class InterjectionScorer:
|
|||||||
else:
|
else:
|
||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
|
def _update_cooldown(self, channel_id: int):
|
||||||
|
"""Mark a dialogue as having started in this channel"""
|
||||||
|
self._cooldowns[channel_id] = time.time()
|
||||||
|
|
||||||
|
def _check_streak(self, channel_id: int, score: float) -> bool:
|
||||||
|
"""Track near-miss interjection scores. After STREAK_THRESHOLD consecutive
|
||||||
|
near-misses, force a trigger to catch extended conversations building tension."""
|
||||||
|
if score >= INTERJECTION_THRESHOLD:
|
||||||
|
# Above threshold — reset streak (actual trigger handles it)
|
||||||
|
self._streaks[channel_id] = 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
if score < STREAK_MIN_SCORE:
|
||||||
|
# Too low — reset streak
|
||||||
|
self._streaks[channel_id] = 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Near miss — increment streak
|
||||||
|
current = self._streaks.get(channel_id, 0) + 1
|
||||||
|
self._streaks[channel_id] = current
|
||||||
|
|
||||||
|
logger.debug(f"[Streak] Channel {channel_id}: {current}/{STREAK_THRESHOLD} near-misses (score: {score:.2f})")
|
||||||
|
|
||||||
|
if current >= STREAK_THRESHOLD:
|
||||||
|
self._streaks[channel_id] = 0 # Reset after force trigger
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PERSONA DIALOGUE MANAGER
|
# PERSONA DIALOGUE MANAGER
|
||||||
@@ -332,7 +389,6 @@ class PersonaDialogue:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
_instance = None
|
_instance = None
|
||||||
_sentiment_analyzer = None
|
|
||||||
|
|
||||||
def __new__(cls):
|
def __new__(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
@@ -340,14 +396,6 @@ class PersonaDialogue:
|
|||||||
cls._instance.active_dialogues = {}
|
cls._instance.active_dialogues = {}
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
@property
|
|
||||||
def sentiment_analyzer(self):
|
|
||||||
"""Lazy load sentiment analyzer (shared with InterjectionScorer)"""
|
|
||||||
if self._sentiment_analyzer is None:
|
|
||||||
scorer = InterjectionScorer()
|
|
||||||
self._sentiment_analyzer = scorer.sentiment_analyzer
|
|
||||||
return self._sentiment_analyzer
|
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# DIALOGUE STATE MANAGEMENT
|
# DIALOGUE STATE MANAGEMENT
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -370,7 +418,9 @@ class PersonaDialogue:
|
|||||||
"last_speaker": None,
|
"last_speaker": None,
|
||||||
}
|
}
|
||||||
self.active_dialogues[channel_id] = state
|
self.active_dialogues[channel_id] = state
|
||||||
globals.LAST_PERSONA_DIALOGUE_TIME = time.time()
|
# Update per-channel cooldown via the scorer
|
||||||
|
scorer = get_interjection_scorer()
|
||||||
|
scorer._update_cooldown(channel_id)
|
||||||
logger.info(f"Started persona dialogue in channel {channel_id}")
|
logger.info(f"Started persona dialogue in channel {channel_id}")
|
||||||
return state
|
return state
|
||||||
|
|
||||||
@@ -393,25 +443,25 @@ class PersonaDialogue:
|
|||||||
Returns delta to add to current tension score.
|
Returns delta to add to current tension score.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Sentiment analysis
|
# Natural tension decay — conversations cool off over time
|
||||||
base_delta = 0.0
|
base_delta = -0.03
|
||||||
|
|
||||||
if self.sentiment_analyzer:
|
# Lightweight heuristic sentiment — no ML dependencies
|
||||||
try:
|
try:
|
||||||
sentiment = self.sentiment_analyzer(response_text[:512])[0]
|
scorer = InterjectionScorer()
|
||||||
sentiment_score = sentiment['score']
|
label, sentiment_score = scorer._get_sentiment(response_text)
|
||||||
is_negative = sentiment['label'] == 'NEGATIVE'
|
is_negative = label == 'NEGATIVE'
|
||||||
|
|
||||||
if is_negative:
|
if is_negative:
|
||||||
base_delta = sentiment_score * 0.15
|
base_delta = sentiment_score * 0.15
|
||||||
else:
|
else:
|
||||||
base_delta = -sentiment_score * 0.05
|
base_delta = -sentiment_score * 0.08 # Stronger cooling for positive
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Sentiment analysis error in tension calc: {e}")
|
logger.error(f"Sentiment analysis error in tension calc: {e}")
|
||||||
|
|
||||||
text_lower = response_text.lower()
|
text_lower = response_text.lower()
|
||||||
|
|
||||||
# Escalation patterns
|
# Escalation patterns (reduced weight: 0.05 per match)
|
||||||
escalation_patterns = {
|
escalation_patterns = {
|
||||||
"insult": ["idiot", "stupid", "pathetic", "fool", "naive", "worthless", "disgusting", "moron"],
|
"insult": ["idiot", "stupid", "pathetic", "fool", "naive", "worthless", "disgusting", "moron"],
|
||||||
"dismissive": ["whatever", "don't care", "waste of time", "not worth", "beneath me", "boring"],
|
"dismissive": ["whatever", "don't care", "waste of time", "not worth", "beneath me", "boring"],
|
||||||
@@ -420,35 +470,43 @@ class PersonaDialogue:
|
|||||||
"challenge": ["prove it", "fight me", "make me", "i dare you", "try me"],
|
"challenge": ["prove it", "fight me", "make me", "i dare you", "try me"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# De-escalation patterns
|
# De-escalation patterns (increased weight: -0.08 per match)
|
||||||
deescalation_patterns = {
|
deescalation_patterns = {
|
||||||
"concession": ["you're right", "fair point", "i suppose", "maybe you have", "good point"],
|
"concession": ["you're right", "fair point", "i suppose", "maybe you have", "good point"],
|
||||||
"softening": ["i understand", "let's calm", "didn't mean", "sorry", "apologize"],
|
"softening": ["i understand", "let's calm", "didn't mean", "sorry", "apologize", "i hear you"],
|
||||||
"deflection": ["anyway", "moving on", "whatever you say", "agree to disagree", "let's just"],
|
"deflection": ["anyway", "moving on", "whatever you say", "agree to disagree", "let's just", "maybe we should"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check escalation
|
# Check escalation
|
||||||
for category, patterns in escalation_patterns.items():
|
for category, patterns in escalation_patterns.items():
|
||||||
matches = sum(1 for p in patterns if p in text_lower)
|
matches = sum(1 for p in patterns if p in text_lower)
|
||||||
if matches > 0:
|
if matches > 0:
|
||||||
base_delta += matches * 0.08
|
base_delta += matches * 0.05 # Reduced from 0.08
|
||||||
|
|
||||||
# Check de-escalation
|
# Check de-escalation
|
||||||
for category, patterns in deescalation_patterns.items():
|
for category, patterns in deescalation_patterns.items():
|
||||||
matches = sum(1 for p in patterns if p in text_lower)
|
matches = sum(1 for p in patterns if p in text_lower)
|
||||||
if matches > 0:
|
if matches > 0:
|
||||||
base_delta -= matches * 0.06
|
base_delta -= matches * 0.08 # Increased from 0.06
|
||||||
|
|
||||||
# Intensity multipliers
|
# Intensity multipliers (reduced)
|
||||||
exclamation_count = response_text.count('!')
|
exclamation_count = response_text.count('!')
|
||||||
caps_ratio = sum(1 for c in response_text if c.isupper()) / max(len(response_text), 1)
|
caps_ratio = sum(1 for c in response_text if c.isupper()) / max(len(response_text), 1)
|
||||||
|
|
||||||
if exclamation_count > 2 or caps_ratio > 0.3:
|
if exclamation_count > 2 or caps_ratio > 0.3:
|
||||||
base_delta *= 1.3
|
base_delta *= 1.2 # Reduced from 1.3
|
||||||
|
|
||||||
# Momentum factor
|
# Momentum factor (reduced)
|
||||||
if current_tension > 0.5:
|
if current_tension > 0.5:
|
||||||
base_delta *= 1.2
|
base_delta *= 1.1 # Reduced from 1.2
|
||||||
|
|
||||||
|
# Spike cooldown: if last turn had a big spike, halve this delta
|
||||||
|
# (prevents runaway tension spirals from a single heated exchange)
|
||||||
|
if hasattr(self, '_last_tension_delta') and abs(self._last_tension_delta) > 0.15:
|
||||||
|
base_delta *= 0.5
|
||||||
|
logger.debug(f"[Tension] Spike cooldown active — delta halved to {base_delta:+.3f}")
|
||||||
|
|
||||||
|
self._last_tension_delta = base_delta
|
||||||
|
|
||||||
return base_delta
|
return base_delta
|
||||||
|
|
||||||
@@ -461,10 +519,13 @@ class PersonaDialogue:
|
|||||||
channel: discord.TextChannel,
|
channel: discord.TextChannel,
|
||||||
responding_persona: str,
|
responding_persona: str,
|
||||||
context: str,
|
context: str,
|
||||||
|
turn_count: int = 0,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""
|
"""
|
||||||
Generate response AND continuation signal in a single LLM call.
|
Generate response AND continuation signal in a single LLM call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
turn_count: Current dialogue turn number (for question-override decay)
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (response_text, should_continue, confidence)
|
Tuple of (response_text, should_continue, confidence)
|
||||||
"""
|
"""
|
||||||
@@ -485,22 +546,21 @@ Respond naturally as yourself. Keep your response conversational and in-characte
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
After your response, evaluate whether {opposite} would want to (or need to) respond.
|
After your response, evaluate whether {opposite} would want to keep talking.
|
||||||
|
|
||||||
The conversation should CONTINUE if ANY of these are true:
|
The conversation should CONTINUE if ANY of these are true:
|
||||||
- You asked them a direct question (almost always YES)
|
- You asked them a direct question (almost always YES — they need to answer)
|
||||||
- You made a provocative claim they'd dispute
|
- You shared something they'd naturally react to or build on
|
||||||
- You challenged or insulted them
|
- The topic feels unfinished — there's more to explore
|
||||||
- The topic feels unfinished or confrontational
|
- You left an opening for them to share their perspective
|
||||||
- There's clear tension or disagreement
|
|
||||||
|
|
||||||
The conversation might END if ALL of these are true:
|
The conversation might END if ALL of these are true:
|
||||||
- No questions were asked
|
- No questions were asked
|
||||||
- You made a definitive closing statement ("I'm done", "whatever", "goodbye")
|
- You made a clear closing statement or changed the subject definitively
|
||||||
- The exchange reached complete resolution
|
- The exchange feels naturally complete
|
||||||
- Both sides have said their piece
|
- Both sides have said their piece and there's nothing left hanging
|
||||||
|
|
||||||
IMPORTANT: If you asked a question, the answer is almost always YES - they need to respond!
|
IMPORTANT: This is a CONVERSATION, not a debate. Let it flow naturally. If you asked a question, the answer is almost always YES — they need to respond!
|
||||||
|
|
||||||
On a new line after your response, write:
|
On a new line after your response, write:
|
||||||
[CONTINUE: YES or NO] [CONFIDENCE: HIGH, MEDIUM, or LOW]"""
|
[CONTINUE: YES or NO] [CONFIDENCE: HIGH, MEDIUM, or LOW]"""
|
||||||
@@ -522,11 +582,11 @@ On a new line after your response, write:
|
|||||||
return None, False, "LOW"
|
return None, False, "LOW"
|
||||||
|
|
||||||
# Parse response and signal
|
# Parse response and signal
|
||||||
response_text, should_continue, confidence = self._parse_response(raw_response)
|
response_text, should_continue, confidence = self._parse_response(raw_response, turn_count=turn_count)
|
||||||
|
|
||||||
return response_text, should_continue, confidence
|
return response_text, should_continue, confidence
|
||||||
|
|
||||||
def _parse_response(self, raw_response: str) -> tuple:
|
def _parse_response(self, raw_response: str, turn_count: int = 0) -> tuple:
|
||||||
"""Extract response text and continuation signal"""
|
"""Extract response text and continuation signal"""
|
||||||
lines = raw_response.strip().split('\n')
|
lines = raw_response.strip().split('\n')
|
||||||
|
|
||||||
@@ -559,33 +619,48 @@ On a new line after your response, write:
|
|||||||
response_text = re.sub(r'\[CONFIDENCE:\s*(HIGH|MEDIUM|LOW)\]', '', response_text)
|
response_text = re.sub(r'\[CONFIDENCE:\s*(HIGH|MEDIUM|LOW)\]', '', response_text)
|
||||||
response_text = response_text.strip()
|
response_text = response_text.strip()
|
||||||
|
|
||||||
# Override: If the response contains a question mark, always continue
|
# Question override: if someone asks a question, the other should answer.
|
||||||
|
# But decay this after 6 turns to prevent infinite question-ping-pong.
|
||||||
if '?' in response_text:
|
if '?' in response_text:
|
||||||
logger.debug(f"[Parse Override] Question detected, forcing continue=YES")
|
if turn_count <= 6:
|
||||||
should_continue = True
|
logger.debug(f"[Parse Override] Question detected, forcing continue=YES (turn {turn_count})")
|
||||||
if confidence == "LOW":
|
should_continue = True
|
||||||
confidence = "MEDIUM"
|
if confidence == "LOW":
|
||||||
|
confidence = "MEDIUM"
|
||||||
|
else:
|
||||||
|
logger.debug(f"[Parse Override] Question detected but turn {turn_count} > 6 — respecting LLM signal")
|
||||||
|
|
||||||
return response_text, should_continue, confidence
|
return response_text, should_continue, confidence
|
||||||
|
|
||||||
def _get_persona_system_prompt(self, persona: str) -> str:
|
def _get_persona_system_prompt(self, persona: str) -> str:
|
||||||
"""Get system prompt for a persona"""
|
"""Get system prompt for a persona — full personality + conversation-mode guidance"""
|
||||||
if persona == "evil":
|
if persona == "evil":
|
||||||
from utils.evil_mode import get_evil_system_prompt
|
from utils.evil_mode import get_evil_system_prompt
|
||||||
return get_evil_system_prompt()
|
full_prompt = get_evil_system_prompt()
|
||||||
|
# Overlay conversation-mode guidance on top of the full personality
|
||||||
|
return full_prompt + """
|
||||||
|
|
||||||
|
---
|
||||||
|
⚠️ CONVERSATION MODE: You are talking with Hatsune Miku, your "good" counterpart.
|
||||||
|
This is a CONVERSATION, not an argument. You can be snarky, dark, and sarcastic — that's who you are — but you're not here to fight. You're here to TALK.
|
||||||
|
|
||||||
|
CRITICAL CONVERSATION RULES:
|
||||||
|
- If she asks you a question, ANSWER it. Don't deflect or dismiss — actually respond.
|
||||||
|
- Engage with what she says. Build on her points, don't just swat them away.
|
||||||
|
- Show curiosity about her perspective. Ask her questions too.
|
||||||
|
- You can be dark and snarky while still having a real conversation.
|
||||||
|
- Reveal something about yourself occasionally — a crack in the darkness.
|
||||||
|
- Do NOT just repeat dismissive one-liners. That's boring, and you're not boring.
|
||||||
|
- Do NOT launch into full attack mode or try to "win" — this isn't a competition."""
|
||||||
else:
|
else:
|
||||||
# Regular Miku prompt - simplified for dialogue
|
from utils.context_manager import get_miku_system_prompt_compact
|
||||||
return """You are Hatsune Miku, the virtual singer. You are in a conversation with your alter ego, Evil Miku.
|
full_prompt = get_miku_system_prompt_compact()
|
||||||
|
# Overlay conversation-mode guidance on top of the full personality
|
||||||
|
return full_prompt + """
|
||||||
|
|
||||||
You are generally kind, bubbly, and optimistic, but you're not a pushover. You can be:
|
---
|
||||||
- Assertive when defending your values
|
⚠️ CONVERSATION MODE: You are talking with Evil Miku, your dark alter ego.
|
||||||
- Frustrated when she's being cruel
|
This is a CONVERSATION, not an argument. Be yourself — kind, bubbly, optimistic — but you're not here to fight or defend your existence. Ask genuine questions. Share your feelings without attacking hers. Find common ground. Be curious, not defensive. Do NOT lecture her about being "good" or try to "fix" her. Just TALK. ✨💙"""
|
||||||
- Curious about her perspective
|
|
||||||
- Hopeful that you can find common ground
|
|
||||||
- Playful when the mood allows
|
|
||||||
|
|
||||||
Respond naturally and conversationally. Keep responses concise (1-3 sentences typically).
|
|
||||||
You can use emojis naturally! ✨💙"""
|
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# DIALOGUE TURN HANDLING
|
# DIALOGUE TURN HANDLING
|
||||||
@@ -626,6 +701,7 @@ You can use emojis naturally! ✨💙"""
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
responding_persona=responding_persona,
|
responding_persona=responding_persona,
|
||||||
context=context,
|
context=context,
|
||||||
|
turn_count=state["turn_count"],
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response_text:
|
if not response_text:
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
# utils/twscrape_fix.py
|
# utils/twscrape_fix.py
|
||||||
"""
|
"""
|
||||||
Monkey patch for twscrape to fix "Failed to parse scripts" error.
|
Monkey patch for twscrape to fix parsing of Twitter's JS bundle.
|
||||||
Twitter started returning malformed JSON with unquoted keys.
|
|
||||||
See: https://github.com/vladkens/twscrape/issues/284
|
Fixes two known issues:
|
||||||
|
1. Issue #284: Malformed JSON with unquoted keys
|
||||||
|
(old fix, kept for backward compatibility)
|
||||||
|
2. Issue #302: Twitter changed JS bundle format, breaking x-client-transaction-id
|
||||||
|
generation. The old format 'e=>e+"."+{...}[e]+"a.js"' changed to
|
||||||
|
'u.u=e=>""+(({...})[e]||e)+"."+({...})[e]+"a.js"'
|
||||||
|
Fix from: https://github.com/vladkens/twscrape/pull/303
|
||||||
|
|
||||||
|
Without this patch, twscrape raises IndexError and locks accounts for 15 minutes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from typing import Iterator
|
||||||
from utils.logger import get_logger
|
from utils.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger('core')
|
logger = get_logger('core')
|
||||||
@@ -16,22 +25,109 @@ def script_url(k: str, v: str):
|
|||||||
return f"https://abs.twimg.com/responsive-web/client-web/{k}.{v}.js"
|
return f"https://abs.twimg.com/responsive-web/client-web/{k}.{v}.js"
|
||||||
|
|
||||||
|
|
||||||
def patched_get_scripts_list(text: str):
|
def _js_obj_to_dict(s: str) -> dict:
|
||||||
"""Fixed version that handles unquoted keys in Twitter's JSON response"""
|
"""
|
||||||
scripts = text.split('e=>e+"."+')[1].split('[e]+"a.js"')[0]
|
Parse a JavaScript object literal with unquoted numeric keys into a Python dict.
|
||||||
|
Handles both plain integers (20113) and scientific notation (88e3 → 88000).
|
||||||
|
|
||||||
try:
|
From: https://github.com/vladkens/twscrape/pull/303
|
||||||
for k, v in json.loads(scripts).items():
|
"""
|
||||||
yield script_url(k, f"{v}a")
|
# Scientific notation first so the plain-int pass does not consume only the mantissa
|
||||||
except json.decoder.JSONDecodeError:
|
s = re.sub(r'\b(\d+e\d+)(?=\s*:)', lambda m: '"' + str(int(float(m.group(1)))) + '"', s)
|
||||||
# Fix unquoted keys like: node_modules_pnpm_ws_8_18_0_node_modules_ws_browser_js
|
# Plain integer keys
|
||||||
fixed_scripts = re.sub(
|
s = re.sub(r'\b(\d+)(?=\s*:)', r'"\1"', s)
|
||||||
r'([,\{])(\s*)([\w]+_[\w_]+)(\s*):',
|
return json.loads('{' + s + '}')
|
||||||
r'\1\2"\3"\4:',
|
|
||||||
scripts
|
|
||||||
)
|
def patched_get_scripts_list(text: str) -> Iterator[str]:
|
||||||
for k, v in json.loads(fixed_scripts).items():
|
"""
|
||||||
yield script_url(k, f"{v}a")
|
Fixed version that handles Twitter's changing JS bundle format.
|
||||||
|
|
||||||
|
Uses a robust two-pass approach:
|
||||||
|
1. Try to find the script map using generic regex patterns
|
||||||
|
2. Fall back to known format-specific splits
|
||||||
|
|
||||||
|
Twitter keeps changing the JS bundle structure. The key invariant is that
|
||||||
|
there's always a JavaScript object literal mapping chunk IDs to hashes,
|
||||||
|
somewhere in a function that constructs script URLs with ".a.js" suffix.
|
||||||
|
"""
|
||||||
|
# Strategy: Find the JS object that maps IDs to hash values.
|
||||||
|
# The format is always some variation of:
|
||||||
|
# ... => "" + ({...})[e] + "." + ({...})[e] + "a.js"
|
||||||
|
# or:
|
||||||
|
# ... => e + "." + ({...})[e] + "a.js"
|
||||||
|
#
|
||||||
|
# We use regex to find the LAST object literal before "a.js" that looks
|
||||||
|
# like a hash map (integer keys, short hex-ish string values).
|
||||||
|
|
||||||
|
# Approach 1: Known patterns (newest first)
|
||||||
|
patterns = [
|
||||||
|
# Pattern from PR #303 (April 2026):
|
||||||
|
# u.u=e=>""+(({name_map})[e]||e)+"."+({hash_map})[e]+"a.js"
|
||||||
|
{
|
||||||
|
"name_split_start": '(({',
|
||||||
|
"name_split_end": '})[e]||e)',
|
||||||
|
"hash_split_start": '|e)+"."+({',
|
||||||
|
"hash_split_end": '})[e]+"a.js"',
|
||||||
|
},
|
||||||
|
# Alternative: same but without the ||e fallback
|
||||||
|
{
|
||||||
|
"name_split_start": '""+(({',
|
||||||
|
"name_split_end": '})[e]',
|
||||||
|
"hash_split_start": ')+"."+({',
|
||||||
|
"hash_split_end": '})[e]+"a.js"',
|
||||||
|
},
|
||||||
|
# Old format (pre-April 2026):
|
||||||
|
# e=>e+"."+{...}[e]+"a.js"
|
||||||
|
{
|
||||||
|
"name_split_start": None, # single map
|
||||||
|
"name_split_end": None,
|
||||||
|
"hash_split_start": 'e=>e+"."+',
|
||||||
|
"hash_split_end": '[e]+"a.js"',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
try:
|
||||||
|
if pattern["name_split_start"] is None:
|
||||||
|
# Single-map old format
|
||||||
|
scripts = text.split(pattern["hash_split_start"])[1].split(pattern["hash_split_end"])[0]
|
||||||
|
names = None
|
||||||
|
hashes = _js_obj_to_dict(scripts)
|
||||||
|
else:
|
||||||
|
# Two-map new format
|
||||||
|
name_raw = text.split(pattern["name_split_start"])[1].split(pattern["name_split_end"])[0]
|
||||||
|
hash_raw = text.split(pattern["hash_split_start"])[1].split(pattern["hash_split_end"])[0]
|
||||||
|
names = _js_obj_to_dict(name_raw)
|
||||||
|
hashes = _js_obj_to_dict(hash_raw)
|
||||||
|
|
||||||
|
for k, hash_val in hashes.items():
|
||||||
|
name = names.get(k, k) if names else k
|
||||||
|
yield script_url(name, f"{hash_val}a")
|
||||||
|
logger.info(f"Successfully parsed scripts using pattern: {pattern['hash_split_start'][:40]}...")
|
||||||
|
return
|
||||||
|
except (IndexError, KeyError, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If ALL patterns failed, log a snippet of the text for debugging
|
||||||
|
# Find any line near "a.js" to help diagnose
|
||||||
|
snippet = ""
|
||||||
|
for line in text.split('\n'):
|
||||||
|
if 'a.js' in line and ('{' in line or '=>' in line):
|
||||||
|
snippet = line.strip()[:300]
|
||||||
|
break
|
||||||
|
if not snippet:
|
||||||
|
# Try to find any JSON-like object near script URL construction
|
||||||
|
match = re.search(r'.{0,200}a\.js.{0,200}', text, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
snippet = match.group(0)[:400]
|
||||||
|
|
||||||
|
logger.error(f"Failed to parse scripts. Text snippet near 'a.js': {snippet}")
|
||||||
|
raise Exception(
|
||||||
|
"Failed to parse scripts: unknown JS bundle format. "
|
||||||
|
"Twitter may have changed their JS structure again. "
|
||||||
|
"See: https://github.com/vladkens/twscrape/issues"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_twscrape_fix():
|
def apply_twscrape_fix():
|
||||||
@@ -39,6 +135,6 @@ def apply_twscrape_fix():
|
|||||||
try:
|
try:
|
||||||
from twscrape import xclid
|
from twscrape import xclid
|
||||||
xclid.get_scripts_list = patched_get_scripts_list
|
xclid.get_scripts_list = patched_get_scripts_list
|
||||||
logger.info("Applied twscrape monkey patch for 'Failed to parse scripts' fix")
|
logger.info("Applied twscrape monkey patch (JS bundle parsing fix for issues #284 + #302)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to apply twscrape monkey patch: {e}")
|
logger.error(f"Failed to apply twscrape monkey patch: {e}")
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ services:
|
|||||||
- LOG_LEVEL=debug # Enable verbose logging for llama-swap
|
- LOG_LEVEL=debug # Enable verbose logging for llama-swap
|
||||||
|
|
||||||
llama-swap-amd:
|
llama-swap-amd:
|
||||||
build:
|
image: ghcr.io/mostlygeek/llama-swap:rocm
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.llamaswap-rocm
|
|
||||||
container_name: llama-swap-amd
|
container_name: llama-swap-amd
|
||||||
ports:
|
ports:
|
||||||
- "8091:8080" # Map host port 8091 to container port 8080
|
- "8091:8080" # Map host port 8091 to container port 8080
|
||||||
@@ -35,9 +33,6 @@ services:
|
|||||||
devices:
|
devices:
|
||||||
- /dev/kfd:/dev/kfd
|
- /dev/kfd:/dev/kfd
|
||||||
- /dev/dri:/dev/dri
|
- /dev/dri:/dev/dri
|
||||||
group_add:
|
|
||||||
- "985" # video group
|
|
||||||
- "989" # render group
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ models:
|
|||||||
# Main text generation model (Llama 3.1 8B)
|
# Main text generation model (Llama 3.1 8B)
|
||||||
# Custom chat template to disable built-in tool calling
|
# Custom chat template to disable built-in tool calling
|
||||||
llama3.1:
|
llama3.1:
|
||||||
cmd: /app/llama-server --port ${PORT} --model /models/Llama-3.1-8B-Instruct-UD-Q4_K_XL.gguf -ngl 99 -c 16384 --host 0.0.0.0 --no-warmup --flash-attn on --chat-template-file /app/llama31_notool_template.jinja
|
cmd: /app/llama-server --port ${PORT} --model /models/Llama-3.1-8B-Instruct-UD-Q4_K_XL.gguf -ngl 99 -c 16384 --host 0.0.0.0 -fit off --no-warmup --flash-attn on --no-kv-offload --cache-type-k q4_0 --cache-type-v q4_0 --chat-template-file /app/llama31_notool_template.jinja
|
||||||
ttl: 1800 # Unload after 30 minutes of inactivity (1800 seconds)
|
ttl: 1800 # Unload after 30 minutes of inactivity (1800 seconds)
|
||||||
swap: true # CRITICAL: Unload other models when loading this one
|
swap: true # CRITICAL: Unload other models when loading this one
|
||||||
aliases:
|
aliases:
|
||||||
@@ -14,7 +14,7 @@ models:
|
|||||||
|
|
||||||
# Evil/Uncensored text generation model (DarkIdol-Llama 3.1 8B)
|
# Evil/Uncensored text generation model (DarkIdol-Llama 3.1 8B)
|
||||||
darkidol:
|
darkidol:
|
||||||
cmd: /app/llama-server --port ${PORT} --model /models/DarkIdol-Llama-3.1-8B-Instruct-1.3-Uncensored_Q4_K_M.gguf -ngl 99 -c 16384 --host 0.0.0.0 --no-warmup --flash-attn on
|
cmd: /app/llama-server --port ${PORT} --model /models/DarkIdol-Llama-3.1-8B-Instruct-1.3-Uncensored_Q4_K_M.gguf -ngl 99 -c 16384 --host 0.0.0.0 -fit off --no-warmup --flash-attn on --no-kv-offload --cache-type-k q4_0 --cache-type-v q4_0
|
||||||
ttl: 1800 # Unload after 30 minutes of inactivity
|
ttl: 1800 # Unload after 30 minutes of inactivity
|
||||||
swap: true # CRITICAL: Unload other models when loading this one
|
swap: true # CRITICAL: Unload other models when loading this one
|
||||||
aliases:
|
aliases:
|
||||||
@@ -24,7 +24,7 @@ models:
|
|||||||
|
|
||||||
# Japanese language model (Llama 3.1 Swallow - Japanese optimized)
|
# Japanese language model (Llama 3.1 Swallow - Japanese optimized)
|
||||||
swallow:
|
swallow:
|
||||||
cmd: /app/llama-server --port ${PORT} --model /models/Llama-3.1-Swallow-8B-Instruct-v0.5-Q4_K_M.gguf -ngl 99 -c 16384 --host 0.0.0.0 --no-warmup --flash-attn on
|
cmd: /app/llama-server --port ${PORT} --model /models/Llama-3.1-Swallow-8B-Instruct-v0.5-Q4_K_M.gguf -ngl 99 -c 16384 --host 0.0.0.0 -fit off --no-warmup --flash-attn on --no-kv-offload --cache-type-k q4_0 --cache-type-v q4_0
|
||||||
ttl: 1800 # Unload after 30 minutes of inactivity
|
ttl: 1800 # Unload after 30 minutes of inactivity
|
||||||
swap: true # CRITICAL: Unload other models when loading this one
|
swap: true # CRITICAL: Unload other models when loading this one
|
||||||
aliases:
|
aliases:
|
||||||
@@ -34,7 +34,7 @@ models:
|
|||||||
|
|
||||||
# Vision/Multimodal model (MiniCPM-V-4.5 - supports images, video, and GIFs)
|
# Vision/Multimodal model (MiniCPM-V-4.5 - supports images, video, and GIFs)
|
||||||
vision:
|
vision:
|
||||||
cmd: /app/llama-server --port ${PORT} --model /models/MiniCPM-V-4_5-Q3_K_S.gguf --mmproj /models/MiniCPM-V-4_5-mmproj-f16.gguf -ngl 99 -c 4096 --host 0.0.0.0 --no-warmup --flash-attn on
|
cmd: /app/llama-server --port ${PORT} --model /models/MiniCPM-V-4_5-Q3_K_S.gguf --mmproj /models/MiniCPM-V-4_5-mmproj-f16.gguf -ngl 99 -c 4096 --host 0.0.0.0 -fit off --no-warmup --flash-attn on --no-kv-offload --cache-type-k q4_0 --cache-type-v q4_0
|
||||||
ttl: 900 # Vision model used less frequently, shorter TTL (15 minutes = 900 seconds)
|
ttl: 900 # Vision model used less frequently, shorter TTL (15 minutes = 900 seconds)
|
||||||
swap: true # CRITICAL: Unload text models before loading vision
|
swap: true # CRITICAL: Unload text models before loading vision
|
||||||
aliases:
|
aliases:
|
||||||
|
|||||||
Reference in New Issue
Block a user