diff --git a/database/db.py b/database/db.py index 40af1ae..38f7a49 100644 --- a/database/db.py +++ b/database/db.py @@ -103,6 +103,8 @@ async def _migrate_personas_columns(db): await db.execute("ALTER TABLE personas ADD COLUMN lorebook_json TEXT DEFAULT '[]'") if "avatar_path" not in cols: await db.execute("ALTER TABLE personas ADD COLUMN avatar_path TEXT DEFAULT ''") + if "alternate_greetings_json" not in cols: + await db.execute("ALTER TABLE personas ADD COLUMN alternate_greetings_json TEXT DEFAULT '[]'") async def _migrate_sessions_columns(db): @@ -126,7 +128,8 @@ async def _migrate_sessions_columns(db): await db.execute("ALTER TABLE sessions ADD COLUMN genre TEXT DEFAULT 'adventure'") if "rpg_settings_json" not in cols: await db.execute("ALTER TABLE sessions ADD COLUMN rpg_settings_json TEXT DEFAULT '{}'") - + if "outfit_json" not in cols: + await db.execute("ALTER TABLE sessions ADD COLUMN outfit_json TEXT DEFAULT '[]'") async def _migrate_rpg_quests(db): await db.executescript(""" @@ -165,3 +168,5 @@ async def _migrate_characters_columns(db): cols = {row[1] for row in await cur.fetchall()} if "avatar_path" not in cols: await db.execute("ALTER TABLE characters ADD COLUMN avatar_path TEXT DEFAULT ''") + if "alternate_greetings_json" not in cols: + await db.execute("ALTER TABLE characters ADD COLUMN alternate_greetings_json TEXT DEFAULT '[]'") diff --git a/fix_empty_messages.py b/fix_empty_messages.py new file mode 100644 index 0000000..5f492c0 --- /dev/null +++ b/fix_empty_messages.py @@ -0,0 +1,6 @@ +import sqlite3 +db = sqlite3.connect("data/chat.db") +cur = db.execute("DELETE FROM messages WHERE role='assistant' AND trim(coalesce(content,''))=''") +print("Deleted:", cur.rowcount, "empty assistant messages") +db.commit() +db.close() diff --git a/models/schemas.py b/models/schemas.py index 00e614a..6ed48a4 100644 --- a/models/schemas.py +++ b/models/schemas.py @@ -7,6 +7,7 @@ class ChatRequest(BaseModel): persona_id: Optional[str] = "default" is_narrator_choice: bool = False skip_user_add: bool = False + first_mes_override: Optional[str] = None class MessageEditRequest(BaseModel): diff --git a/routers/characters.py b/routers/characters.py index 00ae172..22c2036 100644 --- a/routers/characters.py +++ b/routers/characters.py @@ -2,7 +2,14 @@ from fastapi import APIRouter, File, Form, HTTPException, UploadFile from pydantic import BaseModel from typing import Optional -from services.character_card import list_characters, get_character, import_card_file, update_character, update_appearance_tags +from services.character_card import ( + list_characters, + get_character, + import_card_file, + preview_card_file, + update_character, + update_appearance_tags, +) router = APIRouter(prefix="/characters", tags=["characters"]) @@ -17,6 +24,7 @@ class CardPatch(BaseModel): appearance_tags: Optional[str] = None lora_name: Optional[str] = None lora_weight: Optional[float] = None + alternate_greetings_json: Optional[str] = None @router.get("/") @@ -32,6 +40,15 @@ async def get_one(card_id: str): return card +@router.post("/preview") +async def preview_card(file: UploadFile = File(...)): + content = await file.read() + try: + return await preview_card_file(content, file.filename or "card.json") + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + @router.patch("/{card_id}") async def patch_card(card_id: str, body: CardPatch): card = await get_character(card_id) @@ -39,20 +56,26 @@ async def patch_card(card_id: str, body: CardPatch): raise HTTPException(status_code=404, detail="Карточка не найдена") fields = {k: v for k, v in body.model_dump().items() if v is not None} await update_character(card_id, fields) - # sync appearance_tags and lora to persona from services.personas import update_persona_appearance if "appearance_tags" in fields: await update_persona_appearance(f"card_{card_id}", fields["appearance_tags"]) if {"lora_name", "lora_weight"} & fields.keys(): from services.personas import update_persona_lora await update_persona_lora(f"card_{card_id}", fields.get("lora_name"), fields.get("lora_weight")) - # rebuild system prompt if character fields changed char_fields = {"name", "description", "personality", "scenario", "first_mes", "mes_example"} if char_fields & fields.keys(): updated = await get_character(card_id) from services.character_card import build_system_prompt from services.personas import update_persona_prompt await update_persona_prompt(f"card_{card_id}", build_system_prompt(updated)) + if "first_mes" in fields or "alternate_greetings_json" in fields: + from services.personas import patch_persona + sync = {} + if "first_mes" in fields: + sync["first_mes"] = fields["first_mes"] + if "alternate_greetings_json" in fields: + sync["alternate_greetings_json"] = fields["alternate_greetings_json"] + await patch_persona(f"card_{card_id}", sync) return await get_character(card_id) @@ -67,7 +90,6 @@ async def upload_avatar(card_id: str, file: UploadFile = File(...)): from services.character_card import _save_avatar_bytes rel = _save_avatar_bytes(content, f"card_{card_id}") await update_character(card_id, {"avatar_path": rel}) - # sync persona from services.personas import patch_persona await patch_persona(f"card_{card_id}", {"avatar_path": rel}) return {"avatar_path": f"/static/{rel}"} @@ -78,14 +100,35 @@ async def import_card( file: UploadFile = File(...), lora_name: str = Form(""), lora_weight: float = Form(0.8), + card_id: str = Form(""), + name: str = Form(""), + description: str = Form(""), + personality: str = Form(""), + scenario: str = Form(""), + first_mes: str = Form(""), + mes_example: str = Form(""), + appearance_tags: str = Form(""), + alternate_greetings_json: str = Form("[]"), ): content = await file.read() + overrides = { + "name": name or None, + "description": description or None, + "personality": personality or None, + "scenario": scenario or None, + "first_mes": first_mes or None, + "mes_example": mes_example or None, + "appearance_tags": appearance_tags or None, + "alternate_greetings_json": alternate_greetings_json or "[]", + } try: card = await import_card_file( content, file.filename or "card.json", lora_name=lora_name, lora_weight=lora_weight, + overrides=overrides, + card_id=card_id.strip() or None, ) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) @@ -94,6 +137,7 @@ async def import_card( "card_id": card["card_id"], "persona_id": f"card_{card['card_id']}", "name": card["name"], + "alternate_greetings": card.get("alternate_greetings", []), } @@ -104,4 +148,3 @@ async def remove_card(card_id: str): if not await delete_persona(f"card_{card_id}"): raise HTTPException(status_code=404, detail="Карточка не найдена") return {"status": "deleted", "card_id": card_id} - diff --git a/routers/chat.py b/routers/chat.py index 26011f0..02988a6 100644 --- a/routers/chat.py +++ b/routers/chat.py @@ -1,4 +1,5 @@ import json +import logging import os import random @@ -26,6 +27,8 @@ from services.memory import ( update_session_affinity, update_session_genre, update_session_rpg_settings, + update_session_outfit, + update_session_plot_arc, upsert_quest, get_quests, add_action_resolution, @@ -35,84 +38,57 @@ from services.memory import ( delete_message, ) from services.personas import get_persona -from services.sd_prompt import ( - generate_sd_prompt, - strip_image_prompt_tag, - extract_image_prompt_tag, -) +from services.sd_prompt import generate_sd_prompt, strip_image_prompt_tag, extract_image_prompt_tag from services.lorebook import get_lorebook_context from services.character_card import get_character from services import sdbackend as sd_service from services.rpg_facts import extract_facts, merge_facts, facts_to_prompt -from services.rpg_plot import generate_plot_arc, should_advance_arc, pop_matching_beats +from services.rpg_plot import generate_plot_arc, should_advance_arc, pop_matching_beats, advance_phase from services.rpg_narrator import narrator_pre, narrator_post +logger = logging.getLogger(__name__) router = APIRouter(prefix="/chat", tags=["chat"]) DEFAULT_PROMPT = "Ты — полезный AI ассистент. Отвечай чётко и по делу." SD_AUTO_GENERATE = os.getenv("SD_AUTO_GENERATE", "false").lower() in ("1", "true", "yes") -def affinity_prompt_block(affinity: int) -> str: - if affinity >= 10: - tone = "very warm, trusting, affectionate" - elif affinity >= 5: - tone = "friendly and open" - elif affinity >= 1: - tone = "slightly positive" - elif affinity <= -5: - tone = "hostile or deeply distrustful" - elif affinity <= -1: - tone = "cold and wary" - else: - tone = "neutral" - return ( - f"\n\n--- Relationship ---\n" - f"Affinity toward player: {affinity} ({tone}). " - f"Reflect this in your attitude and word choice.\n---" - ) - - -DEFAULT_RPG_SETTINGS = { - "dice": True, - "narrator": True, - "quests": True, - "affinity": True, - "choices": True, -} +DEFAULT_RPG_SETTINGS = {"dice": True, "narrator": True, "quests": True, "affinity": True, "choices": True} def get_rpg_settings(session: dict) -> dict: try: - s = json.loads(session.get("rpg_settings_json") or "{}") - return {**DEFAULT_RPG_SETTINGS, **s} + return {**DEFAULT_RPG_SETTINGS, **json.loads(session.get("rpg_settings_json") or "{}")} except Exception: return DEFAULT_RPG_SETTINGS +def affinity_prompt_block(affinity: int) -> str: + if affinity >= 10: tone = "very warm, trusting, affectionate" + elif affinity >= 5: tone = "friendly and open" + elif affinity >= 1: tone = "slightly positive" + elif affinity <= -5: tone = "hostile or deeply distrustful" + elif affinity <= -1: tone = "cold and wary" + else: tone = "neutral" + return f"\n\n--- Relationship ---\nAffinity toward player: {affinity} ({tone}). Reflect this in your attitude and word choice.\n---" + + async def get_system_prompt(persona_id: str, history: list, user_message: str = "") -> str: persona = await get_persona(persona_id) if not persona: return DEFAULT_PROMPT - prompt = persona["prompt"] - + recent = [m for m in history if m["role"] in ("user", "assistant")][-5:] + context = recent + [{"role": "user", "content": user_message}] if persona.get("lorebook_json"): - recent = [m for m in history if m["role"] in ("user", "assistant")][-5:] - context = recent + [{"role": "user", "content": user_message}] lore = get_lorebook_context(persona.get("lorebook_json", "[]"), context) if lore: - prompt = prompt + "\n\n" + lore - + prompt += "\n\n" + lore if persona_id.startswith("card_"): - card_id = persona_id[5:] - card = await get_character(card_id) + card = await get_character(persona_id[5:]) if card: - recent = [m for m in history if m["role"] in ("user", "assistant")][-5:] - context = recent + [{"role": "user", "content": user_message}] lore = get_lorebook_context(card.get("lorebook_json", "[]"), context) if lore: - prompt = prompt + "\n\n" + lore - + prompt += "\n\n" + lore return prompt @@ -126,12 +102,18 @@ async def get_system_blob(session_id: str): history = await get_history(session_id) system_msg = next((m for m in history if m.get("role") == "system"), None) session = await get_session(session_id) + quests = await get_quests(session_id) return { "system_prompt": system_msg.get("content") if system_msg else "", - "facts_json": session.get("facts_json") if session else "[]", "status_quo": session.get("status_quo") if session else "", + "facts_json": session.get("facts_json") if session else "[]", "plot_arc_json": session.get("plot_arc_json") if session else "{}", + "outfit_json": session.get("outfit_json") if session else "[]", + "affinity": session.get("affinity", 0) if session else 0, + "genre": session.get("genre", "") if session else "", + "rpg_settings_json": session.get("rpg_settings_json") if session else "{}", "rpg_enabled": bool(session.get("rpg_enabled")) if session else False, + "quests": quests, } @@ -147,15 +129,19 @@ async def init_chat(request: ChatRequest): await add_message(request.session_id, "system", system_prompt) first_mes = None - persona = await get_persona(persona_id) - if persona and persona.get("first_mes"): - first_mes = persona["first_mes"] + if request.first_mes_override and request.first_mes_override.strip(): + first_mes = request.first_mes_override.strip() await add_message(request.session_id, "assistant", first_mes) - elif persona_id.startswith("card_"): - card = await get_character(persona_id[5:]) - if card and card.get("first_mes"): - first_mes = card["first_mes"] + else: + persona = await get_persona(persona_id) + if persona and persona.get("first_mes"): + first_mes = persona["first_mes"] await add_message(request.session_id, "assistant", first_mes) + elif persona_id.startswith("card_"): + card = await get_character(persona_id[5:]) + if card and card.get("first_mes"): + first_mes = card["first_mes"] + await add_message(request.session_id, "assistant", first_mes) return {"first_mes": first_mes} @@ -196,9 +182,9 @@ async def rpg_bootstrap(req: RpgBootstrapRequest): # Seed quests from beats for beat in arc.get("beats", []): - injection = beat.get("injection", "").strip() - if injection: - await upsert_quest(req.session_id, injection[:120]) + title = (beat.get("title") or beat.get("injection", "")).strip() + if title: + await upsert_quest(req.session_id, title[:120]) quests = await get_quests(req.session_id) return {"plot_arc": arc, "quests": quests} @@ -339,11 +325,21 @@ async def chat_stream(request: ChatRequest): async def generate(): nonlocal arc - async for chunk in stream_message( - [{"role": m["role"], "content": m["content"]} for m in messages] - ): - full_reply.append(chunk) - yield f"data: {json.dumps({'chunk': chunk})}\n\n" + + # Send narrator BEFORE streaming so it appears above the reply + if narrator_msg: + yield f"data: {json.dumps({'narrator': narrator_msg})}\n\n" + + try: + async for chunk in stream_message( + [{"role": m["role"], "content": m["content"]} for m in messages] + ): + full_reply.append(chunk) + yield f"data: {json.dumps({'chunk': chunk})}\n\n" + except Exception as e: + logger.error("stream_message failed: %s", e) + yield f"data: {json.dumps({'error': str(e)})}\n\n" + return complete = "".join(full_reply) display_text = strip_image_prompt_tag(complete) @@ -351,17 +347,14 @@ async def chat_stream(request: ChatRequest): hist_with_reply = await get_history(request.session_id) + [ {"role": "assistant", "content": display_text} ] - sd_result = await generate_sd_prompt(hist_with_reply, persona_id) - prompt_str = sd_result[0] if sd_result else None - if not prompt_str: - prompt_str = extract_image_prompt_tag(complete) - - await add_message( - request.session_id, - "assistant", - display_text or complete, - image_prompt=prompt_str, + sd_result = await generate_sd_prompt( + hist_with_reply, persona_id, + outfit_json=session.get("outfit_json", "[]") if session else "[]" ) + prompt_str = (sd_result[0] if sd_result and sd_result[0] else None) or extract_image_prompt_tag(complete) + + if (display_text or complete).strip(): + await add_message(request.session_id, "assistant", display_text or complete, image_prompt=prompt_str) choices = [] debug_blocks = [] @@ -370,38 +363,36 @@ async def chat_stream(request: ChatRequest): if session and session.get("rpg_enabled"): if not arc: persona = await get_persona(persona_id) or {} - genre = (session.get("genre") or "adventure") arc = await generate_plot_arc( persona.get("name", persona_id), persona.get("description", ""), persona.get("scenario", ""), persona.get("first_mes", ""), facts_block=facts_to_prompt(session.get("facts_json", "[]")), - genre=genre, + genre=session.get("genre") or "adventure", ) if arc: - from services.memory import update_session_plot_arc await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False)) debug_blocks.append({"type": "plot_arc", "text": json.dumps(arc, ensure_ascii=False, indent=2)}) if rpg_settings.get("quests", True): for beat in arc.get("beats", []): - inj = beat.get("injection", "").strip() - if inj: - await upsert_quest(request.session_id, inj[:120]) + t = (beat.get("title") or beat.get("injection", "")).strip() + if t: + await upsert_quest(request.session_id, t[:120]) trig = should_advance_arc(request.message) if trig and arc: arc, beats = pop_matching_beats(arc, trig, max_beats=1) if beats: - from services.memory import update_session_plot_arc await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False)) inj = beats[0].get("injection", "") if inj: debug_blocks.append({"type": "narrator_injection", "text": inj}) if rpg_settings.get("choices", True): - beat_choices = beats[0].get("choices") or [] - if beat_choices: - choices = choices + beat_choices + choices += beats[0].get("choices") or [] + if advance_phase(arc): + await update_session_plot_arc(request.session_id, json.dumps(arc, ensure_ascii=False)) + debug_blocks.append({"type": "phase_advance", "text": arc["phase"]}) ctx = [m for m in (await get_history(request.session_id)) if m["role"] in ("user", "assistant")][-10:] new_facts = await extract_facts(ctx) @@ -409,55 +400,53 @@ async def chat_stream(request: ChatRequest): merged = merge_facts(session.get("facts_json", "[]"), new_facts) await update_session_facts(request.session_id, merged) session["facts_json"] = merged - debug_blocks.append({"type": "facts", "text": facts_to_prompt(merged)}) persona = await get_persona(persona_id) or {} - ctx_txt = "\n".join( - f"{m['role']}: {m['content']}" for m in ctx[-8:] - if m.get("role") in ("user", "assistant") - ) + ctx_txt = "\n".join(f"{m['role']}: {m['content']}" for m in ctx[-8:] if m.get("role") in ("user", "assistant")) post = await narrator_post( persona.get("name", persona_id), ctx_txt, json.dumps(arc, ensure_ascii=False) if arc else "", facts_to_prompt(session.get("facts_json", "[]")), ) + sq = (post.get("status_quo_update") or "").strip() if sq: await update_session_status_quo(request.session_id, sq) - session["status_quo"] = sq - debug_blocks.append({"type": "status_quo", "text": f"--- Status quo ---\n{sq}\n---"}) + debug_blocks.append({"type": "status_quo", "text": sq}) if rpg_settings.get("choices", True): - extra_choices = post.get("choices") or [] - if extra_choices: - choices = choices + extra_choices + choices += post.get("choices") or [] if rpg_settings.get("affinity", True): delta = int(post.get("affinity_delta") or 0) if delta: await update_session_affinity(request.session_id, delta) + outfit_update = post.get("outfit_update") + if isinstance(outfit_update, list) and outfit_update: + outfit_str = json.dumps(outfit_update, ensure_ascii=False) + await update_session_outfit(request.session_id, outfit_str) + session["outfit_json"] = outfit_str + if rpg_settings.get("quests", True): for qu in (post.get("quest_updates") or []): - title = (qu.get("title") or "").strip() - status = qu.get("status", "active") - if title: - await upsert_quest(request.session_id, title[:120], status) + t = (qu.get("title") or "").strip() + if t: + await upsert_quest(request.session_id, t[:120], qu.get("status", "active")) quests_updated = await get_quests(request.session_id) count = await get_message_count(request.session_id) if count == 2 and not request.skip_user_add: persona = await get_persona(persona_id) or {} - persona_name = persona.get("name", persona_id) preview = request.message[:40] + ("…" if len(request.message) > 40 else "") - current = (session or {}).get("title") or "Новый чат" - if current in ("", "Новый чат"): - await update_session_title(request.session_id, f"{persona_name} — {preview}") + if (session or {}).get("title", "Новый чат") in ("", "Новый чат"): + await update_session_title(request.session_id, f"{persona.get('name', persona_id)} — {preview}") image_path = None image_error = None if prompt_str and SD_AUTO_GENERATE: + yield f"data: {json.dumps({'image_generating': True, 'image_prompt': prompt_str})}\n\n" rel, err = await sd_service.generate_from_full_prompt(prompt_str) if rel: image_path = rel @@ -467,21 +456,10 @@ async def chat_stream(request: ChatRequest): else: image_error = err - # Fetch current affinity for UI updated_session = await get_session(request.session_id) affinity = updated_session.get("affinity", 0) if updated_session else 0 - yield f"data: {json.dumps({ - 'done': True, - 'image_prompt': prompt_str, - 'image_path': f'/static/{image_path}' if image_path else None, - 'image_error': image_error, - 'choices': choices, - 'debug': debug_blocks, - 'narrator': narrator_msg, - 'affinity': affinity, - 'quests': quests_updated, - })}\n\n" + yield f"data: {json.dumps({'done': True, 'image_prompt': prompt_str, 'image_path': f'/static/{image_path}' if image_path else None, 'image_error': image_error, 'choices': choices, 'debug': debug_blocks, 'affinity': affinity, 'quests': quests_updated})}\n\n" return StreamingResponse( generate(), diff --git a/routers/sessions.py b/routers/sessions.py index ea6673a..c2a38b5 100644 --- a/routers/sessions.py +++ b/routers/sessions.py @@ -1,11 +1,11 @@ from fastapi import APIRouter, HTTPException from services.memory import ( get_all_sessions, + get_session, get_or_create_session, delete_session, update_session_title, update_session_persona, - get_history, get_message_count, update_session_rpg, update_session_facts, @@ -16,7 +16,6 @@ from services.memory import ( get_quests, get_last_message_preview, fork_session, - get_session, ) from models.schemas import ForkSessionRequest @@ -30,11 +29,7 @@ async def list_sessions(): for s in sessions: count = await get_message_count(s["session_id"]) preview = await get_last_message_preview(s["session_id"]) - result.append({ - **s, - "message_count": count, - "last_message_preview": preview, - }) + result.append({**s, "message_count": count, "last_message_preview": preview}) return result @@ -44,9 +39,8 @@ async def list_quests(session_id: str): @router.get("/{session_id}") -async def get_session(session_id: str): - sessions = await get_all_sessions() - s = next((x for x in sessions if x["session_id"] == session_id), None) +async def get_session_route(session_id: str): + s = await get_session(session_id) if not s: raise HTTPException(status_code=404, detail="Сессия не найдена") return s @@ -86,4 +80,3 @@ async def fork_session_route(session_id: str, req: ForkSessionRequest): async def remove_session(session_id: str): await delete_session(session_id) return {"status": "deleted", "session_id": session_id} - diff --git a/services/character_card.py b/services/character_card.py index 5fc2137..2d0e398 100644 --- a/services/character_card.py +++ b/services/character_card.py @@ -7,7 +7,19 @@ import aiosqlite from database.db import DB_PATH -def parse_card_v2(data: dict) -> dict: +def _normalize_alternate_greetings(inner: dict) -> list[str]: + raw = inner.get("alternate_greetings") or [] + if not isinstance(raw, list): + return [] + out = [] + for item in raw: + text = str(item).strip() + if text and text not in out: + out.append(text) + return out + + +def parse_card_v2(data: dict, card_id: str | None = None) -> dict: inner = data.get("data", data) if isinstance(inner, str): inner = json.loads(inner) @@ -17,12 +29,15 @@ def parse_card_v2(data: dict) -> dict: if isinstance(entries, dict): entries = list(entries.values()) + alternates = _normalize_alternate_greetings(inner) + cid = card_id or ( + inner.get("name", "imported").lower().replace(" ", "_")[:48] + + "_" + + uuid.uuid4().hex[:8] + ) + return { - "card_id": ( - inner.get("name", "imported").lower().replace(" ", "_")[:48] - + "_" - + uuid.uuid4().hex[:8] - ), + "card_id": cid, "name": inner.get("name", "Character"), "description": inner.get("description", ""), "personality": inner.get("personality", ""), @@ -31,10 +46,22 @@ def parse_card_v2(data: dict) -> dict: "mes_example": inner.get("mes_example", ""), "appearance_tags": _extract_appearance(inner), "lorebook_json": json.dumps(entries, ensure_ascii=False), + "alternate_greetings": alternates, + "alternate_greetings_json": json.dumps(alternates, ensure_ascii=False), "raw_json": json.dumps(data if "data" in data else {"data": inner}, ensure_ascii=False), } +def parse_card_bytes(content: bytes, filename: str) -> dict: + if filename.lower().endswith(".png"): + card = parse_png_card(content) + if not card: + raise ValueError("PNG does not contain character card metadata") + card["_png_bytes"] = content + return card + return parse_card_v2(json.loads(content.decode("utf-8"))) + + def _extract_appearance(inner: dict) -> str: """Extract booru-style appearance tags from character fields.""" import re @@ -107,12 +134,18 @@ def build_system_prompt(card: dict) -> str: async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0.8) -> dict: card_id = card["card_id"] + alt_json = card.get("alternate_greetings_json") + if alt_json is None: + alts = card.get("alternate_greetings") or [] + alt_json = json.dumps(alts, ensure_ascii=False) if isinstance(alts, list) else "[]" + async with aiosqlite.connect(DB_PATH) as db: await db.execute( """INSERT OR REPLACE INTO characters (card_id, name, description, personality, scenario, first_mes, - mes_example, raw_json, lora_name, lora_weight, appearance_tags, lorebook_json, avatar_path) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + mes_example, raw_json, lora_name, lora_weight, appearance_tags, lorebook_json, + avatar_path, alternate_greetings_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( card_id, card["name"], @@ -127,6 +160,7 @@ async def save_character(card: dict, lora_name: str = "", lora_weight: float = 0 card.get("appearance_tags", ""), card["lorebook_json"], card.get("avatar_path", ""), + alt_json, ), ) await db.commit() @@ -140,7 +174,7 @@ async def get_character(card_id: str) -> dict | None: "SELECT * FROM characters WHERE card_id = ?", (card_id,) ) as cur: row = await cur.fetchone() - return dict(row) if row else None + return card_to_api(dict(row)) if row else None async def list_characters() -> list: @@ -171,9 +205,31 @@ async def update_appearance_tags(card_id: str, appearance_tags: str): await db.commit() +def card_to_api(card: dict) -> dict: + alts = card.get("alternate_greetings") + if alts is None: + try: + alts = json.loads(card.get("alternate_greetings_json") or "[]") + except Exception: + alts = [] + if not isinstance(alts, list): + alts = [] + return {**card, "alternate_greetings": alts} + + +async def preview_card_file(content: bytes, filename: str) -> dict: + card = parse_card_bytes(content, filename) + png_bytes = card.pop("_png_bytes", None) + preview = card_to_api(card) + preview["is_png"] = bool(png_bytes) + preview["alternate_count"] = len(preview.get("alternate_greetings") or []) + return preview + + async def update_character(card_id: str, fields: dict) -> bool: allowed = {"name", "description", "personality", "scenario", "first_mes", - "mes_example", "appearance_tags", "lora_name", "lora_weight", "avatar_path"} + "mes_example", "appearance_tags", "lora_name", "lora_weight", "avatar_path", + "alternate_greetings_json"} updates = {k: v for k, v in fields.items() if k in allowed} if not updates: return False @@ -187,37 +243,72 @@ async def update_character(card_id: str, fields: dict) -> bool: return cur.rowcount > 0 -async def import_card_file(content: bytes, filename: str, lora_name: str = "", lora_weight: float = 0.8) -> dict: - if filename.lower().endswith(".png"): - card = parse_png_card(content) - if not card: - raise ValueError("PNG does not contain character card metadata") - # Use the PNG itself as avatar - avatar_rel = _save_avatar_bytes(content, f"card_{card['card_id']}") +async def import_card_file( + content: bytes, + filename: str, + lora_name: str = "", + lora_weight: float = 0.8, + overrides: dict | None = None, + card_id: str | None = None, +) -> dict: + card = parse_card_bytes(content, filename) + png_bytes = card.pop("_png_bytes", None) + + if card_id: + card["card_id"] = card_id + + if overrides: + for key in ( + "name", "description", "personality", "scenario", "first_mes", + "mes_example", "appearance_tags", "lorebook_json", + ): + if key in overrides and overrides[key] is not None: + card[key] = overrides[key] + if overrides.get("alternate_greetings_json") is not None: + card["alternate_greetings_json"] = overrides["alternate_greetings_json"] + elif overrides.get("alternate_greetings") is not None: + alts = overrides["alternate_greetings"] + if isinstance(alts, str): + try: + alts = json.loads(alts) + except Exception: + alts = [] + card["alternate_greetings"] = alts + card["alternate_greetings_json"] = json.dumps(alts, ensure_ascii=False) + + if png_bytes: + avatar_rel = _save_avatar_bytes(png_bytes, f"card_{card['card_id']}") card["avatar_path"] = avatar_rel - else: - card = parse_card_v2(json.loads(content.decode("utf-8"))) saved = await save_character(card, lora_name=lora_name, lora_weight=lora_weight) persona_id = f"card_{saved['card_id']}" - from services.personas import create_persona, get_persona + from services.personas import create_persona, get_persona, patch_persona existing = await get_persona(persona_id) + persona_fields = { + "name": saved["name"], + "emoji": "🎭", + "description": (saved["description"] or "")[:80] or "Character card", + "prompt": build_system_prompt(saved), + "sd_enabled": True, + "lora_name": lora_name, + "lora_weight": lora_weight, + "appearance_tags": saved.get("appearance_tags", ""), + "avatar_path": saved.get("avatar_path", ""), + "personality": saved.get("personality", ""), + "scenario": saved.get("scenario", ""), + "first_mes": saved.get("first_mes", ""), + "mes_example": saved.get("mes_example", ""), + "lorebook_json": saved.get("lorebook_json", "[]"), + "alternate_greetings_json": saved.get("alternate_greetings_json", "[]"), + } if not existing: - await create_persona( - persona_id=persona_id, - name=saved["name"], - emoji="🎭", - description=saved["description"][:80] or "Character card", - prompt=build_system_prompt(saved), - sd_enabled=True, - lora_name=lora_name, - lora_weight=lora_weight, - appearance_tags=saved.get("appearance_tags", ""), - avatar_path=saved.get("avatar_path", ""), - ) - return saved + await create_persona(persona_id=persona_id, **persona_fields) + else: + await patch_persona(persona_id, persona_fields) + + return card_to_api(saved) def _save_avatar_bytes(png_bytes: bytes, prefix: str) -> str: diff --git a/services/llm.py b/services/llm.py index e489fc7..2b4ae89 100644 --- a/services/llm.py +++ b/services/llm.py @@ -1,12 +1,18 @@ import httpx +import json +import logging import os from dotenv import load_dotenv load_dotenv() +logger = logging.getLogger(__name__) + OPENROUTER_KEY = os.getenv("ROUTER_KEY") OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" -MODEL = "google/gemini-2.5-flash" + +CHAT_MODEL = os.getenv("CHAT_MODEL", "mistralai/mistral-nemo") +SYSTEM_MODEL = os.getenv("SYSTEM_MODEL", "google/gemini-2.5-flash") HEADERS = { "Authorization": f"Bearer {OPENROUTER_KEY}", @@ -14,66 +20,67 @@ HEADERS = { "HTTP-Referer": "http://localhost:8000", } + +def _clean(messages: list) -> list: + """Filter out messages with empty content.""" + return [m for m in messages if (m.get("content") or "").strip()] + + +async def _post(model: str, messages: list, extra: dict | None = None) -> str: + payload = {"model": model, "messages": _clean(messages), **(extra or {})} + async with httpx.AsyncClient(timeout=90) as client: + r = await client.post(OPENROUTER_URL, headers=HEADERS, json=payload) + r.raise_for_status() + return r.json()["choices"][0]["message"]["content"] + + async def send_message(messages: list) -> str: - """Обычный запрос — используем для внутренних нужд""" - payload = { - "model": MODEL, - "messages": messages, - } - async with httpx.AsyncClient(timeout=60) as client: - response = await client.post( - OPENROUTER_URL, - headers=HEADERS, - json=payload - ) - response.raise_for_status() - data = response.json() - return data["choices"][0]["message"]["content"] + """System model — narrator, facts, SD prompt.""" + return await _post(SYSTEM_MODEL, messages) async def send_message_with_model(messages: list, model: str) -> str: - payload = { - "model": model, - "messages": messages, - } - async with httpx.AsyncClient(timeout=90) as client: - response = await client.post( - OPENROUTER_URL, - headers=HEADERS, - json=payload - ) - response.raise_for_status() - data = response.json() - return data["choices"][0]["message"]["content"] + """Explicit model — plot arc, narrator override.""" + return await _post(model, messages) async def stream_message(messages: list): - """Стриминг — отдаём чанки по мере получения""" + """Chat model stream — roleplay dialogue.""" payload = { - "model": MODEL, - "messages": messages, + "model": CHAT_MODEL, + "messages": _clean(messages), "stream": True, } - async with httpx.AsyncClient(timeout=60) as client: - async with client.stream( - "POST", - OPENROUTER_URL, - headers=HEADERS, - json=payload - ) as response: - response.raise_for_status() - async for line in response.aiter_lines(): - if not line.startswith("data: "): - continue - data = line[6:] # убираем "data: " - if data == "[DONE]": - break - try: - import json - chunk = json.loads(data) - delta = chunk["choices"][0]["delta"] - content = delta.get("content", "") - if content: - yield content - except Exception: - continue + timeout = httpx.Timeout(connect=10, read=120, write=10, pool=5) + chunk_count = 0 + async with httpx.AsyncClient(timeout=timeout) as client: + try: + async with client.stream("POST", OPENROUTER_URL, headers=HEADERS, json=payload) as response: + response.raise_for_status() + buf = "" + async for raw in response.aiter_bytes(): + text = raw.decode("utf-8", errors="replace") + if not buf and chunk_count == 0: + logger.info("stream first bytes: %.200s", text) + buf += text + while "\n" in buf: + line, buf = buf.split("\n", 1) + line = line.rstrip("\r") + if not line.startswith("data: "): + continue + data = line[6:] + if data == "[DONE]": + return + try: + chunk = json.loads(data) + content = chunk["choices"][0]["delta"].get("content", "") + if content: + chunk_count += 1 + yield content + except Exception: + continue + except Exception as e: + logger.error("stream_message error after %d chunks: %s", chunk_count, e) + raise + finally: + logger.info("stream_message finished: %d chunks", chunk_count) diff --git a/services/memory.py b/services/memory.py index cc81fc6..25f5980 100644 --- a/services/memory.py +++ b/services/memory.py @@ -380,6 +380,15 @@ async def update_session_rpg_settings(session_id: str, settings_json: str): await db.commit() +async def update_session_outfit(session_id: str, outfit_json: str): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE sessions SET outfit_json = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", + (outfit_json, session_id), + ) + await db.commit() + + async def upsert_quest(session_id: str, title: str, status: str = "active"): async with aiosqlite.connect(DB_PATH) as db: async with db.execute( diff --git a/services/personas.py b/services/personas.py index fcf9e31..e77d3a7 100644 --- a/services/personas.py +++ b/services/personas.py @@ -69,6 +69,7 @@ def _row_to_persona(row: dict) -> dict: "mes_example": row.get("mes_example", "") or "", "lorebook_json": row.get("lorebook_json", "[]") or "[]", "avatar_path": row.get("avatar_path", "") or "", + "alternate_greetings_json": row.get("alternate_greetings_json", "[]") or "[]", } @@ -122,6 +123,7 @@ async def create_persona( mes_example: str = "", lorebook_json: str = "[]", avatar_path: str = "", + alternate_greetings_json: str = "[]", ) -> dict: final_prompt = prompt.strip() or build_persona_prompt( { @@ -137,12 +139,14 @@ async def create_persona( """INSERT INTO personas (persona_id, name, emoji, description, prompt, custom, sd_enabled, lora_name, lora_weight, appearance_tags, - personality, scenario, first_mes, mes_example, lorebook_json, avatar_path) - VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + personality, scenario, first_mes, mes_example, lorebook_json, avatar_path, + alternate_greetings_json) + VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( persona_id, name, emoji, description, final_prompt, 1 if sd_enabled else 0, lora_name, lora_weight, appearance_tags, personality, scenario, first_mes, mes_example, lorebook_json, avatar_path, + alternate_greetings_json, ), ) await db.commit() @@ -162,6 +166,7 @@ async def create_persona( "mes_example": mes_example, "lorebook_json": lorebook_json, "avatar_path": avatar_path, + "alternate_greetings_json": alternate_greetings_json, } @@ -227,6 +232,7 @@ async def patch_persona(persona_id: str, fields: dict) -> bool: "mes_example", "lorebook_json", "avatar_path", + "alternate_greetings_json", } updates = {k: v for k, v in fields.items() if k in allowed} if not updates: diff --git a/services/rpg_narrator.py b/services/rpg_narrator.py index 8c1344f..a611baf 100644 --- a/services/rpg_narrator.py +++ b/services/rpg_narrator.py @@ -35,12 +35,14 @@ Return ONLY valid JSON (no markdown): "facts": ["durable facts only"], "choices": [{"id":"a","label":"..."}, ...], "affinity_delta": 0, - "quest_updates": [{"title": "quest title", "status": "active|done|failed"}] + "quest_updates": [{"title": "quest title", "status": "active|done|failed"}], + "outfit_update": ["danbooru_tag", "danbooru_tag"] } Rules: - affinity_delta: integer -2..+2. Positive if character warmed up to player, negative if pushed away. 0 if neutral. - quest_updates: only include if a quest was clearly started, completed, or failed. Empty array otherwise. -- choices: 0-4 options for what the player can do next.""" +- choices: 0-4 options for what the player can do next. +- outfit_update: ONLY include if the character's clothing visibly changed (put on, took off, changed outfit). Use exact danbooru-style underscore_tags (e.g. ["white_dress", "red_ribbon", "barefoot"]). Empty array if no change.""" async def narrator_pre( diff --git a/services/rpg_plot.py b/services/rpg_plot.py index eb19cf8..c964813 100644 --- a/services/rpg_plot.py +++ b/services/rpg_plot.py @@ -27,7 +27,7 @@ Return ONLY valid JSON (no markdown): "cast": [{"name":"NPC name","role":"helper|antagonist|bystander","motivation":"..."}], "secrets": ["hidden truths not revealed yet"], "beats": [ - {"id":"b1","trigger":"event_driven:rest|event_driven:travel|event_driven:help_request|event_driven:after_fail|event_driven:after_success", + {"id":"b1","title":"short quest title (3-6 words)","trigger":"event_driven:rest|event_driven:travel|event_driven:help_request|event_driven:after_fail|event_driven:after_success", "injection":"1-3 sentences to introduce the beat WITHOUT breaking current scene", "choices":[{"id":"a","label":"..."},{"id":"b","label":"..."}]} ], @@ -90,6 +90,24 @@ def should_advance_arc(user_text: str) -> str | None: return None +PHASE_ORDER = ["opening", "hook", "complication", "reveal", "climax", "aftermath"] + + +def advance_phase(arc: dict) -> bool: + """Advance arc to next phase if beats are exhausted. Returns True if phase changed.""" + current = arc.get("phase", "opening") + if arc.get("beats"): + return False + try: + idx = PHASE_ORDER.index(current) + except ValueError: + return False + if idx + 1 >= len(PHASE_ORDER): + return False + arc["phase"] = PHASE_ORDER[idx + 1] + return True + + def pop_matching_beats(arc: dict, trigger: str, max_beats: int = 1) -> tuple[dict, list[dict]]: beats = arc.get("beats", []) if not isinstance(beats, list): diff --git a/services/sd_prompt.py b/services/sd_prompt.py index 398596e..9e0b31b 100644 --- a/services/sd_prompt.py +++ b/services/sd_prompt.py @@ -1,20 +1,24 @@ import json +import logging import os import re -from services.llm import send_message + +from services.llm import send_message, send_message_with_model from services.personas import get_persona +logger = logging.getLogger(__name__) + PROMPT_BUILDER_SYSTEM = """You are a Stable Diffusion prompt engineer for anime illustration models. -Given a roleplay chat excerpt and character appearance hints, output ONLY valid JSON (no markdown): +Given a roleplay chat excerpt, output ONLY valid JSON (no markdown): { "should_generate": true, "shot_type": "first_person_pov" | "landscape" | "third_person", - "appearance_tags": "booru-style tags for character appearance extracted from hints, e.g. 'white hair, wolf ears, wolf tail, yellow eyes'", - "action_tags": "booru-style tags for pose/action, e.g. 'sitting, smiling, looking at viewer'", - "environment_tags": "booru-style tags for location/lighting, e.g. 'indoors, kitchen, sunlight'" + "action_tags": "booru-style tags for pose/action/expression, e.g. 'sitting, smiling, holding_cup'", + "environment_tags": "booru-style tags for location/lighting/time, e.g. 'indoors, kitchen, sunlight, daytime'" } Rules: -- ONLY use real danbooru/e621 tags. Multi-word concepts MUST be written as single tags: 'white hair' not 'white, hair'. 'wolf ears' not 'wolf, ears'. +- ONLY use real danbooru/e621 tags. Multi-word concepts MUST be underscore_joined: 'fox_ears' not 'fox ears'. +- Do NOT include appearance/character tags — those are provided separately. - Do NOT include quality tags, model names, style words, 'pov', or category/metadata words. - Do NOT invent tags. If unsure — omit. - Keep each field to 3-6 tags.""" @@ -35,19 +39,38 @@ def strip_image_prompt_tag(text: str) -> str: return re.sub(r"\[IMAGE_PROMPT:.*?\]", "", text, flags=re.DOTALL).strip() -PONY_CHECKPOINTS = {"ponyDiffusionV6XL_v6StartWithThisOne.safetensors"} SD_CHECKPOINT = os.getenv("SD_CHECKPOINT", "") -PONY_NEGATIVE = "score_1, score_2, score_3, score_4, worst quality, low quality, blurry, bad anatomy, watermark, text, censored" +SD_UNET = os.getenv("SD_UNET", "") +SD_PROMPT_MODEL = os.getenv("SD_PROMPT_MODEL", "").strip() + +PONY_CHECKPOINTS = {"ponyDiffusionV6XL_v6StartWithThisOne.safetensors"} +PONY_NEGATIVE = "score_1, score_2, score_3, score_4, worst quality, low quality, blurry, bad anatomy, watermark, text, censored" +ANIMA_NEGATIVE = "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia" + + +def _is_pony() -> bool: + return SD_CHECKPOINT in PONY_CHECKPOINTS + + +def _is_anima() -> bool: + return bool(SD_UNET) and not SD_CHECKPOINT + + +def build_positive_prompt(scene: dict, persona: dict | None, outfit_tags: str = "") -> str: + if _is_pony(): + quality = "score_9, score_8_up, score_7_up, source_anime, highres" + elif _is_anima(): + quality = "masterpiece, best quality, score_7, anime" + else: + quality = "masterpiece, best quality, highres" -def build_positive_prompt(scene: dict, persona: dict | None) -> str: - is_pony = SD_CHECKPOINT in PONY_CHECKPOINTS - quality = "score_9, score_8_up, score_7_up, source_anime, highres" if is_pony else "masterpiece, best quality, highres" parts = [quality] - # prefer LLM-extracted appearance over raw persona tags - appearance = scene.get("appearance_tags") or (persona or {}).get("appearance_tags", "") + appearance = (persona or {}).get("appearance_tags", "") if appearance: parts.append(appearance) + if outfit_tags: + parts.append(outfit_tags) if scene.get("shot_type") == "landscape": parts.append(scene.get("environment_tags", "")) @@ -75,9 +98,12 @@ def build_positive_prompt(scene: dict, persona: dict | None) -> str: async def generate_sd_prompt( messages: list, persona_id: str, + outfit_json: str = "[]", ) -> tuple[str | None, str | None]: persona = await get_persona(persona_id) - if not persona or not persona.get("sd_enabled"): + # Generate only if persona has appearance tags + if not persona or not (persona.get("appearance_tags") or "").strip(): + logger.debug("sd_prompt skip: persona=%s no appearance_tags", persona_id) return None, None recent = [m for m in messages if m["role"] in ("user", "assistant")][-6:] @@ -86,40 +112,45 @@ async def generate_sd_prompt( excerpt = "\n".join(f"{m['role']}: {strip_image_prompt_tag(m['content'])}" for m in recent) - appearance = persona.get("appearance_tags", "") - # For card personas, also include description for better visual context - if persona_id.startswith("card_"): - from services.character_card import get_character - card = await get_character(persona_id[5:]) - if card and card.get("description"): - appearance = f"{appearance}\nCharacter description: {card['description'][:400]}" - builder_messages = [ {"role": "system", "content": PROMPT_BUILDER_SYSTEM}, - { - "role": "user", - "content": f"Persona appearance hints: {appearance}\n\nChat:\n{excerpt}", - }, + {"role": "user", "content": f"Chat:\n{excerpt}"}, ] try: - raw = await send_message(builder_messages) + if SD_PROMPT_MODEL: + raw = await send_message_with_model(builder_messages, SD_PROMPT_MODEL) + else: + raw = await send_message(builder_messages) raw = raw.strip() if raw.startswith("```"): raw = re.sub(r"^```\w*\n?", "", raw) raw = re.sub(r"\n?```$", "", raw) scene = json.loads(raw) - except (json.JSONDecodeError, Exception): + if not isinstance(scene, dict): + logger.warning("sd_prompt: LLM returned non-dict: %.100s", raw) + return None, None + except Exception as e: + logger.warning("sd_prompt failed: %s raw=%.200s", e, locals().get("raw", "")) return None, None + try: + outfit_list = json.loads(outfit_json or "[]") + outfit_tags = ", ".join(outfit_list) if isinstance(outfit_list, list) else "" + except Exception: + outfit_tags = "" + + positive = build_positive_prompt(scene, persona, outfit_tags) + + if _is_pony(): + negative = PONY_NEGATIVE + elif _is_anima(): + negative = ANIMA_NEGATIVE + else: + negative = "low quality, blurry, bad anatomy, watermark, text" - positive = build_positive_prompt(scene, persona) - is_pony = SD_CHECKPOINT in PONY_CHECKPOINTS - negative = PONY_NEGATIVE if is_pony else "low quality, blurry, bad anatomy, watermark, text" if scene.get("shot_type") == "first_person_pov": negative += ", third person, over the shoulder" - full = positive - if negative: - full += f"\n\nNegative prompt: {negative}" + full = positive + f"\n\nNegative prompt: {negative}" return full, negative diff --git a/services/sdbackend.py b/services/sdbackend.py index fbd0691..aa3874f 100644 --- a/services/sdbackend.py +++ b/services/sdbackend.py @@ -16,13 +16,26 @@ SD_STEPS = int(os.getenv("SD_STEPS", "28")) SD_CFG = float(os.getenv("SD_CFG", "7")) SD_SAMPLER = os.getenv("SD_SAMPLER", "euler") SD_SCHEDULER = os.getenv("SD_SCHEDULER", "normal") -SD_CHECKPOINT = os.getenv("SD_CHECKPOINT", "NetaYumev35_pretrained_all_in_one.safetensors") +SD_CHECKPOINT = os.getenv("SD_CHECKPOINT", "") SD_DEFAULT_NEGATIVE = os.getenv( "SD_DEFAULT_NEGATIVE", "low quality, worst quality, blurry, bad anatomy, watermark, text", ) + +# Anima split-model settings +SD_UNET = os.getenv("SD_UNET", "anima-preview3-base.safetensors") +SD_CLIP = os.getenv("SD_CLIP", "qwen_3_06b_base.safetensors") +SD_VAE = os.getenv("SD_VAE", "qwen_image_vae.safetensors") + IMAGES_DIR = Path(os.getenv("IMAGES_DIR", "static/images")) +ANIMA_CHECKPOINTS = {"anima-preview3-base.safetensors"} +PONY_CHECKPOINTS = {"ponyDiffusionV6XL_v6StartWithThisOne.safetensors"} + + +def _use_anima() -> bool: + return bool(SD_UNET) and not SD_CHECKPOINT + def split_prompt_and_negative(full_prompt: str) -> tuple[str, str]: if "\n\nNegative prompt:" in full_prompt: @@ -32,26 +45,44 @@ def split_prompt_and_negative(full_prompt: str) -> tuple[str, str]: def _build_workflow(positive: str, negative: str) -> dict: - """Minimal KSampler workflow for ComfyUI API.""" + seed = int(uuid.uuid4().int % 2**32) + if _use_anima(): + return { + "44": {"class_type": "UNETLoader", "inputs": {"unet_name": SD_UNET, "weight_dtype": "default"}}, + "45": {"class_type": "CLIPLoader", "inputs": {"clip_name": SD_CLIP, "type": "stable_diffusion", "device": "default"}}, + "15": {"class_type": "VAELoader", "inputs": {"vae_name": SD_VAE}}, + "28": {"class_type": "EmptyLatentImage", "inputs": {"width": 1024, "height": 1024, "batch_size": 1}}, + "11": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["45", 0]}}, + "12": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["45", 0]}}, + "19": { + "class_type": "KSampler", + "inputs": { + "model": ["44", 0], "positive": ["11", 0], "negative": ["12", 0], + "latent_image": ["28", 0], "seed": seed, + "steps": SD_STEPS, "cfg": SD_CFG, + "sampler_name": os.getenv("SD_SAMPLER", "er_sde"), + "scheduler": os.getenv("SD_SCHEDULER", "simple"), + "denoise": 1.0, + }, + }, + "8": {"class_type": "VAEDecode", "inputs": {"samples": ["19", 0], "vae": ["15", 0]}}, + "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "chatbot", "images": ["8", 0]}}, + } + # Standard checkpoint workflow (Pony / SDXL) return { - "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": SD_CHECKPOINT}}, - "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 832, "height": 1216, "batch_size": 1}}, - "6": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["4", 1]}}, - "7": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["4", 1]}}, - "8": {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0], "vae": ["4", 2]}}, - "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "chatbot", "images": ["8", 0]}}, + "4": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": SD_CHECKPOINT}}, + "5": {"class_type": "EmptyLatentImage", "inputs": {"width": 832, "height": 1216, "batch_size": 1}}, + "6": {"class_type": "CLIPTextEncode", "inputs": {"text": positive, "clip": ["4", 1]}}, + "7": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["4", 1]}}, + "8": {"class_type": "VAEDecode", "inputs": {"samples": ["10", 0], "vae": ["4", 2]}}, + "9": {"class_type": "SaveImage", "inputs": {"filename_prefix": "chatbot", "images": ["8", 0]}}, "10": { "class_type": "KSampler", "inputs": { - "model": ["4", 0], - "positive": ["6", 0], - "negative": ["7", 0], - "latent_image": ["5", 0], - "seed": int(uuid.uuid4().int % 2**32), - "steps": SD_STEPS, - "cfg": SD_CFG, - "sampler_name": SD_SAMPLER, - "scheduler": SD_SCHEDULER, + "model": ["4", 0], "positive": ["6", 0], "negative": ["7", 0], + "latent_image": ["5", 0], "seed": seed, + "steps": SD_STEPS, "cfg": SD_CFG, + "sampler_name": SD_SAMPLER, "scheduler": SD_SCHEDULER, "denoise": 1.0, }, }, @@ -74,7 +105,6 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte logger.info("ComfyUI request → %s prompt: %.120s", SD_BASE_URL, prompt) async with httpx.AsyncClient(timeout=300) as client: - # queue the prompt resp = await client.post( f"{SD_BASE_URL}/prompt", json={"prompt": workflow, "client_id": client_id}, @@ -83,14 +113,17 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte prompt_id = resp.json()["prompt_id"] logger.info("ComfyUI queued prompt_id=%s", prompt_id) - # poll until done for _ in range(300): await asyncio.sleep(1) hist = await client.get(f"{SD_BASE_URL}/history/{prompt_id}") data = hist.json() if prompt_id in data: - outputs = data[prompt_id]["outputs"] - # find first image output + entry = data[prompt_id] + # Log any errors from ComfyUI + if entry.get("status", {}).get("status_str") == "error": + msgs = entry.get("status", {}).get("messages", []) + logger.error("ComfyUI workflow error: %s", msgs) + outputs = entry.get("outputs", {}) for node_output in outputs.values(): if "images" in node_output: img_info = node_output["images"][0] @@ -100,12 +133,13 @@ async def txt2img(prompt: str, negative_prompt: str | None = None) -> tuple[byte ) img_resp.raise_for_status() image_bytes = img_resp.content - IMAGES_DIR.mkdir(parents=True, exist_ok=True) filename = f"{uuid.uuid4().hex}.png" (IMAGES_DIR / filename).write_bytes(image_bytes) logger.info("ComfyUI done → saved %s", filename) return image_bytes, f"images/{filename}" + logger.error("ComfyUI no image output. status=%s outputs_keys=%s", + entry.get("status"), list(outputs.keys())) break raise RuntimeError("ComfyUI generation timed out or produced no output") diff --git a/static/avatars/card_luna_-_your_wolfgirl_stepmother_e8e3594d_2adc6d90.png b/static/avatars/card_luna_-_your_wolfgirl_stepmother_e8e3594d_2adc6d90.png new file mode 100644 index 0000000..b5466d0 Binary files /dev/null and b/static/avatars/card_luna_-_your_wolfgirl_stepmother_e8e3594d_2adc6d90.png differ diff --git a/static/avatars/card_yuki_9b731699_b10d8ee1.png b/static/avatars/card_yuki_9b731699_b10d8ee1.png new file mode 100644 index 0000000..50782eb Binary files /dev/null and b/static/avatars/card_yuki_9b731699_b10d8ee1.png differ diff --git a/static/avatars/card_yuki_b4fa6f15_c71bdec9.png b/static/avatars/card_yuki_b4fa6f15_c71bdec9.png new file mode 100644 index 0000000..50782eb Binary files /dev/null and b/static/avatars/card_yuki_b4fa6f15_c71bdec9.png differ diff --git a/static/css/app.css b/static/css/app.css index 3788697..6930b96 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -136,11 +136,13 @@ header h1 { font-size: 1.1rem; color: #e94560; } .system-blob-header { display: flex; align-items: center; + gap: 6px; justify-content: space-between; color: #888; font-size: 0.8rem; margin-bottom: 6px; } +.system-blob-header span { flex: 1; } .system-blob-header button { background: transparent; border: 1px solid #0f3460; @@ -150,6 +152,10 @@ header h1 { font-size: 1.1rem; color: #e94560; } cursor: pointer; } .system-blob-header button:hover { border-color: #e94560; color: #e94560; } +#systemBlobRefresh { font-size: 1rem; padding: 2px 8px; } +#systemBlobRefresh.spinning { animation: spin 0.6s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.blob-changed { background: rgba(255, 200, 50, 0.15); border-radius: 3px; transition: background 2s ease; } .system-blob-content { white-space: pre-wrap; word-break: break-word; @@ -278,6 +284,31 @@ header h1 { font-size: 1.1rem; color: #e94560; } .translate-btn:disabled { opacity: 0.5; cursor: default; } .chat-image { margin-top: 8px; max-width: 100%; border-radius: 8px; border: 1px solid #0f3460; } + +.image-generating { + display: flex; + align-items: center; + gap: 10px; + margin-top: 8px; + padding: 12px 14px; + border-radius: 8px; + border: 1px dashed #533483; + background: rgba(15, 52, 96, 0.45); + color: #bbb; + font-size: 0.9em; +} +.image-generating-spinner { + width: 18px; + height: 18px; + border: 2px solid #0f3460; + border-top-color: #e94560; + border-radius: 50%; + animation: image-spin 0.75s linear infinite; + flex-shrink: 0; +} +@keyframes image-spin { to { transform: rotate(360deg); } } + +.gen-image-btn:disabled { opacity: 0.6; cursor: wait; } .image-error { margin-top: 6px; font-size: 0.75rem; color: #888; } .choice-row { @@ -407,11 +438,13 @@ textarea:focus { border-color: #e94560; } .wizard-nav-btn:disabled { opacity: 0.45; cursor: not-allowed; } .modal h2 { font-size: 1.1rem; color: #e94560; } .modal label { display: flex; flex-direction: column; gap: 4px; font-size: 0.8rem; color: #888; } -.modal input, .modal textarea { +.modal input, .modal textarea, .modal select { background: #1a1a2e; border: 1px solid #0f3460; border-radius: 8px; color: #e0e0e0; padding: 8px 10px; outline: none; font-family: inherit; } +.modal select { cursor: pointer; } +.modal select[size] { min-height: 80px; } .modal-buttons { display: flex; gap: 8px; justify-content: flex-end; } .modal-wizard-footer { justify-content: space-between; align-items: center; } .modal-buttons button { padding: 8px 18px; border-radius: 8px; border: none; cursor: pointer; } diff --git a/static/index.html b/static/index.html index 1e022eb..98b9646 100644 --- a/static/index.html +++ b/static/index.html @@ -35,6 +35,7 @@
—@@ -126,20 +127,53 @@