from __future__ import annotations import base64 import html import mimetypes import os import time from pathlib import Path from typing import Any import gradio as gr from src.character_registry import ( character_display_name, format_character_card_markdown, get_character, get_character_packages, ) from src.character_workshop import ( LOGIN_REQUIRED_MESSAGE, create_draft_from_form, create_draft_from_tavern_json, create_initial_state, ensure_user_workshop_run, generate_background, generate_expression_pack, generate_main_candidates, get_current_user, install_character_package, list_user_workshop_runs, load_workshop_run, matte_and_package_assets, record_workshop_event, render_packaged_stage_preview, require_login_for_generation, select_main_candidate, summarize_workshop_stats, ) from src.dialogue_engine import stream_reply from src.model_status import ( check_all_statuses, check_image_generation_status, initial_model_statuses, llm_loading_status, statuses_json, statuses_markdown, statuses_with_llm_status, warm_llm_model, ) from src.stage_driver import render_character_stage ROOT = Path(__file__).resolve().parent APP_CSS = """ :root, body { color-scheme: light; } .gradio-container { background: radial-gradient(circle at 10% 8%, rgba(103, 232, 249, .22), transparent 28%), radial-gradient(circle at 82% 0%, rgba(251, 191, 36, .20), transparent 26%), linear-gradient(135deg, #eef7f8 0%, #f8f4ec 46%, #f4eef2 100%); color: #172033; } #vc-root { max-width: 1500px; margin: 0 auto; } .vc-topbar { align-items: stretch; flex-wrap: wrap; } .vc-status-wrap { min-width: min(760px, 100%); } .vc-status-actions { justify-content: center; gap: 8px; } .vc-status-actions button { width: 100%; min-height: 38px; } .vc-model-action { border: 1px solid rgba(212, 212, 216, .13); border-radius: 8px; background: rgba(18, 20, 24, .66); padding: 10px 12px; } .vc-model-action p { margin: 0; color: rgba(244, 244, 245, .82); font-size: 13px; line-height: 1.45; } .vc-tabs { margin-top: 8px; } .vc-panel { border: 1px solid rgba(212, 212, 216, .13); border-radius: 8px; background: rgba(18, 20, 24, .82); box-shadow: 0 18px 52px rgba(0, 0, 0, .24); backdrop-filter: blur(18px); padding: 12px; } .vc-panel .label-wrap, .vc-input-dock .label-wrap { color: rgba(228, 228, 231, .82); } .vc-card-head { display: grid; grid-template-columns: 72px 1fr; gap: 12px; align-items: center; padding: 4px 0 12px; border-bottom: 1px solid rgba(212, 212, 216, .12); margin-bottom: 12px; } .vc-card-head img { width: 72px; height: 72px; object-fit: cover; border-radius: 8px; background: #09090b; border: 1px solid rgba(103, 232, 249, .30); box-shadow: 0 0 26px rgba(103, 232, 249, .14); } .vc-card-title { display: flex; flex-direction: column; gap: 7px; } .vc-card-title strong { font-size: 18px; letter-spacing: 0; color: #f4f4f5; } .vc-tags { display: flex; flex-wrap: wrap; gap: 6px; } .vc-tags span { border: 1px solid rgba(103, 232, 249, .24); background: rgba(8, 145, 178, .13); color: #a5f3fc; border-radius: 999px; padding: 2px 8px; font-size: 12px; line-height: 18px; } .vc-character-card { max-height: 67vh; overflow: auto; padding-right: 4px; } .vc-character-card h3 { display: none; } .vc-character-card p, .vc-character-card li, .vc-character-card blockquote { font-size: 13px; line-height: 1.62; } .vc-character-card strong { color: #fbbf24; } .vc-character-card blockquote { border-left: 3px solid #fb7185; background: rgba(251, 113, 133, .08); margin: 6px 0 12px; padding: 8px 10px; } #character-stage { min-height: 560px; } #character-stage > div { min-height: 560px; } .vc-stage2 { height: min(72vh, 680px); min-height: 560px; position: relative; overflow: hidden; border-radius: 8px; background: radial-gradient(circle at 74% 18%, rgba(103, 232, 249, .18), transparent 32%), linear-gradient(145deg, var(--bg), #09090b 74%); background-size: auto, auto; background-position: center, center; color: #eef2ff; font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; isolation: isolate; } .vc-stage2::before { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(255,255,255,.07) 1px, transparent 1px), linear-gradient(0deg, rgba(255,255,255,.05) 1px, transparent 1px); background-size: 72px 72px; mask-image: linear-gradient(180deg, transparent, #000 18%, #000 72%, transparent); opacity: .08; transform: perspective(700px) rotateX(58deg) translateY(140px) scale(1.4); transform-origin: 50% 100%; z-index: 1; } .vc-stage2::after { content: ""; position: absolute; inset: auto 0 0 0; height: 38%; background: linear-gradient(0deg, rgba(5, 10, 18, .62), rgba(5, 10, 18, .01)); z-index: 1; pointer-events: none; } .vc-stage-top { position: absolute; z-index: 4; left: 18px; right: 18px; top: 16px; display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 13px; color: #f8fafc; pointer-events: none; } .vc-bg { position: absolute; z-index: 0; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: .86; filter: saturate(.95) brightness(.86); } .vc-name { display: inline-flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: 0; min-height: 32px; max-width: min(58%, 280px); padding: 6px 12px; border: 1px solid rgba(103, 232, 249, .42); border-radius: 999px; background: linear-gradient(135deg, rgba(7, 15, 25, .82), rgba(15, 23, 42, .66)); color: #f8fafc; box-shadow: 0 10px 28px rgba(2, 6, 23, .32), inset 0 1px 0 rgba(255, 255, 255, .10); text-shadow: 0 1px 8px rgba(0, 0, 0, .72); backdrop-filter: blur(12px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .vc-name::before { content: ""; width: 10px; height: 10px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 18px var(--accent); } .vc-status { min-height: 32px; max-width: min(42%, 240px); padding: 6px 12px; border: 1px solid rgba(251, 191, 36, .44); border-radius: 999px; background: linear-gradient(135deg, rgba(24, 16, 10, .82), rgba(63, 40, 12, .60)); color: #fff7ed; box-shadow: 0 10px 28px rgba(2, 6, 23, .30), inset 0 1px 0 rgba(255, 255, 255, .10); text-shadow: 0 1px 8px rgba(0, 0, 0, .74); backdrop-filter: blur(12px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .vc-spotlight { position: absolute; z-index: 1; left: 50%; top: 8%; width: 420px; height: 460px; transform: translateX(-50%); border-radius: 999px; background: radial-gradient(circle, rgba(103, 232, 249, .28), transparent 68%); filter: blur(10px); opacity: var(--talk-glow); animation: vc2-pulse 3.2s ease-in-out infinite; } .vc-portrait-wrap { position: absolute; z-index: 3; left: 50%; bottom: 10px; width: min(76%, 470px); height: calc(100% - 66px); display: flex; align-items: flex-end; justify-content: center; transform: translateX(-50%) scale(var(--focus)); transform-origin: 50% 92%; filter: drop-shadow(0 34px 42px rgba(0, 0, 0, .46)); animation: vc2-breathe 4.6s ease-in-out infinite; } .vc-portrait { display: block; width: auto; max-width: 100%; max-height: 100%; height: auto; object-fit: contain; user-select: none; pointer-events: none; } .vc-ground { position: absolute; z-index: 2; left: 50%; bottom: 12px; width: 390px; height: 42px; transform: translateX(-50%); border-radius: 999px; background: radial-gradient(ellipse, rgba(0, 0, 0, .46), transparent 70%); filter: blur(3px); } .vc-motion-talk .vc-portrait-wrap { animation: vc2-talk 1.15s ease-in-out infinite; } .vc-motion-focus .vc-portrait-wrap, .vc-motion-look .vc-portrait-wrap { animation: vc2-focus 2.8s ease-in-out infinite; } .vc-motion-sway .vc-portrait-wrap { animation: vc2-sway 4s ease-in-out infinite; } .vc-motion-blink .vc-portrait-wrap { animation: vc2-blink 3.4s ease-in-out infinite; } @keyframes vc2-breathe { 0%, 100% { transform: translateX(-50%) translateY(0) scale(var(--focus)); } 50% { transform: translateX(-50%) translateY(-7px) scale(calc(var(--focus) + .012)); } } @keyframes vc2-talk { 0%, 100% { transform: translateX(-50%) translateY(0) scale(var(--focus)); filter: brightness(1); } 42% { transform: translateX(-50%) translateY(-8px) scale(calc(var(--focus) + .015)); filter: brightness(1.06); } 70% { transform: translateX(-50%) translateY(-3px) scale(calc(var(--focus) + .006)); } } @keyframes vc2-focus { 0%, 100% { transform: translateX(-50%) translateY(0) rotate(-.4deg) scale(var(--focus)); } 50% { transform: translateX(-50%) translateY(-6px) rotate(.7deg) scale(calc(var(--focus) + .01)); } } @keyframes vc2-sway { 0%, 100% { transform: translateX(-50%) rotate(-1deg) scale(var(--focus)); } 50% { transform: translateX(-50%) rotate(1.2deg) translateY(-5px) scale(calc(var(--focus) + .01)); } } @keyframes vc2-blink { 0%, 100% { transform: translateX(-50%) translateY(0) scale(var(--focus)); opacity: 1; } 48% { transform: translateX(-50%) translateY(-4px) scale(var(--focus)); opacity: .98; } 52% { transform: translateX(-50%) translateY(-4px) scale(1.006); opacity: .94; } } @keyframes vc2-pulse { 0%, 100% { opacity: var(--talk-glow); transform: translateX(-50%) scale(.98); } 50% { opacity: calc(var(--talk-glow) + .08); transform: translateX(-50%) scale(1.04); } } .vc-output #component-0, .vc-output textarea, .vc-output .wrap { background: rgba(9, 9, 11, .42); } .vc-model-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } .vc-workshop-grid { display: grid; gap: 12px; } .vc-workshop-status p { margin: 0; color: rgba(228, 228, 231, .78); font-size: 13px; } .vc-workshop-shell { position: relative; isolation: isolate; } .gradio-container .wrap.full, .gradio-container .wrap.minimal, .gradio-container .wrap.center.full, .gradio-container .wrap.default.full, .gradio-container .wrap.default.minimal { opacity: 0 !important; background: transparent !important; pointer-events: none !important; } .vc-model-pill { min-height: 84px; border: 1px solid rgba(212, 212, 216, .14); border-radius: 8px; background: rgba(24, 24, 27, .72); padding: 10px 12px; display: grid; gap: 4px; position: relative; overflow: hidden; } .vc-model-pill::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #71717a; } .vc-model-pill b { font-size: 13px; color: #f4f4f5; } .vc-model-pill span { font-size: 13px; color: #e4e4e7; } .vc-model-pill small { font-size: 11px; color: rgba(212, 212, 216, .62); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .vc-model-pill em { font-style: normal; font-size: 12px; color: rgba(228, 228, 231, .74); } .vc-model-ready::before { background: #22c55e; box-shadow: 0 0 16px rgba(34, 197, 94, .46); } .vc-model-loading::before, .vc-model-sleeping::before, .vc-model-unknown::before { background: #f59e0b; box-shadow: 0 0 16px rgba(245, 158, 11, .42); } .vc-model-error::before { background: #fb7185; box-shadow: 0 0 16px rgba(251, 113, 133, .42); } .vc-model-local::before { background: #67e8f9; box-shadow: 0 0 16px rgba(103, 232, 249, .42); } .vc-model-unconfigured::before { background: #71717a; } .vc-input-dock { margin-top: 12px; border: 1px solid rgba(103, 232, 249, .20); border-radius: 8px; background: rgba(229, 231, 235, .96); box-shadow: 0 -18px 60px rgba(0, 0, 0, .28); padding: 12px; color: #111827; } #vc-message { border-radius: 8px; background: #f8fafc !important; color: #111827 !important; border: 1px solid rgba(8, 145, 178, .28); } #vc-message, #vc-message * { color: #111827 !important; } #vc-message textarea, #vc-message input, #vc-message [contenteditable="true"] { background: #f8fafc !important; color: #111827 !important; caret-color: #0891b2 !important; } #vc-message textarea::placeholder, #vc-message input::placeholder { color: #64748b !important; } #vc-message button { background: rgba(8, 145, 178, .10) !important; border-color: rgba(8, 145, 178, .24) !important; color: #0f172a !important; } #vc-message svg { color: #0f172a !important; stroke: currentColor !important; } #voice-output { min-height: 72px; } .vc-audio-status p { margin: 0; color: rgba(228, 228, 231, .76); font-size: 13px; } @media (max-width: 980px) { .vc-stage-col { order: 1; } .vc-left { order: 2; } .vc-right { order: 3; } .vc-status-wrap, .vc-status-actions { min-width: 100% !important; } .vc-model-grid { grid-template-columns: 1fr; } .vc-character-card { max-height: none; } #character-stage, #character-stage > div { min-height: 480px; } } .gradio-container { color-scheme: light; --vc-page-text: #172033; --vc-page-muted: #526173; --vc-page-bg: radial-gradient(circle at 10% 8%, rgba(103, 232, 249, .22), transparent 28%), radial-gradient(circle at 82% 0%, rgba(251, 191, 36, .20), transparent 26%), linear-gradient(135deg, #eef7f8 0%, #f8f4ec 46%, #f4eef2 100%); --vc-panel-bg: rgba(255, 255, 255, .86); --vc-panel-border: rgba(15, 23, 42, .13); --vc-panel-shadow: 0 18px 45px rgba(30, 41, 59, .14); --vc-card-strong: #a16207; --vc-tag-bg: rgba(8, 145, 178, .10); --vc-tag-text: #0e7490; --vc-pill-bg: rgba(255, 255, 255, .84); --vc-pill-text: #172033; --vc-pill-muted: #64748b; --vc-input-bg: rgba(255, 255, 255, .92); --vc-input-text: #172033; --vc-input-muted: #526173; --vc-chat-bg: rgba(255, 255, 255, .78); background: var(--vc-page-bg) !important; color: var(--vc-page-text) !important; } .gradio-container:has(#vc-mode-night) { color-scheme: dark; --vc-page-text: #e5e7eb; --vc-page-muted: rgba(228, 228, 231, .72); --vc-page-bg: radial-gradient(circle at 12% 14%, rgba(103, 232, 249, .12), transparent 30%), radial-gradient(circle at 82% 8%, rgba(251, 191, 36, .10), transparent 28%), linear-gradient(135deg, rgba(8, 11, 16, .98), rgba(15, 16, 18, .98) 48%, rgba(20, 12, 17, .98)); --vc-panel-bg: rgba(18, 20, 24, .82); --vc-panel-border: rgba(212, 212, 216, .13); --vc-panel-shadow: 0 18px 52px rgba(0, 0, 0, .24); --vc-card-strong: #fbbf24; --vc-tag-bg: rgba(8, 145, 178, .13); --vc-tag-text: #a5f3fc; --vc-pill-bg: rgba(24, 24, 27, .72); --vc-pill-text: #f4f4f5; --vc-pill-muted: rgba(212, 212, 216, .62); --vc-input-bg: rgba(229, 231, 235, .96); --vc-input-text: #111827; --vc-input-muted: #475569; --vc-chat-bg: rgba(9, 9, 11, .42); } .vc-panel { background: var(--vc-panel-bg) !important; border-color: var(--vc-panel-border) !important; box-shadow: var(--vc-panel-shadow) !important; color: var(--vc-page-text) !important; } .vc-input-dock { background: var(--vc-input-bg) !important; border-color: var(--vc-panel-border) !important; box-shadow: var(--vc-panel-shadow) !important; color: var(--vc-input-text) !important; } .vc-panel *, .vc-character-card, .vc-character-card p, .vc-character-card li, .vc-character-card blockquote, .vc-card-title small { color: var(--vc-page-text); } .vc-panel .label-wrap { color: var(--vc-page-muted) !important; } .vc-input-dock .label-wrap { color: var(--vc-input-muted) !important; } .vc-card-head { border-bottom-color: var(--vc-panel-border) !important; } .vc-card-title strong { color: var(--vc-page-text) !important; } .vc-tags span { background: var(--vc-tag-bg) !important; color: var(--vc-tag-text) !important; border-color: rgba(8, 145, 178, .22) !important; } .vc-character-card strong { color: var(--vc-card-strong) !important; } .vc-character-card blockquote { color: var(--vc-page-text) !important; background: rgba(251, 113, 133, .09) !important; } .vc-model-pill { background: var(--vc-pill-bg) !important; border-color: var(--vc-panel-border) !important; } .vc-model-pill b, .vc-model-pill span { color: var(--vc-pill-text) !important; } .vc-model-pill small, .vc-model-pill em { color: var(--vc-pill-muted) !important; } .vc-output textarea, .vc-output .wrap, .vc-output [data-testid="chatbot"] { background: var(--vc-chat-bg) !important; color: var(--vc-page-text) !important; } .vc-audio-status p { color: var(--vc-page-muted) !important; } .vc-input-dock { background: var(--vc-input-bg) !important; } .gradio-container:not(:has(#vc-mode-night)) .vc-stage2 { box-shadow: 0 24px 64px rgba(15, 23, 42, .20); } .gradio-container:not(:has(#vc-mode-night)) .vc-bg { opacity: .94; filter: saturate(1.03) brightness(1.04); } .gradio-container:not(:has(#vc-mode-night)) .vc-stage2::after { background: linear-gradient(0deg, rgba(15, 23, 42, .36), rgba(15, 23, 42, .01)); } .vc-appearance-mode label { color: var(--vc-page-text) !important; } .gradio-container:not(:has(#vc-mode-night)) .vc-appearance-mode, .gradio-container:not(:has(#vc-mode-night)) .vc-appearance-mode *, .gradio-container:not(:has(#vc-mode-night)) input, .gradio-container:not(:has(#vc-mode-night)) textarea, .gradio-container:not(:has(#vc-mode-night)) select, .gradio-container:not(:has(#vc-mode-night)) button { color: #172033 !important; } .gradio-container:not(:has(#vc-mode-night)) input, .gradio-container:not(:has(#vc-mode-night)) textarea, .gradio-container:not(:has(#vc-mode-night)) select, .gradio-container:not(:has(#vc-mode-night)) .wrap, .gradio-container:not(:has(#vc-mode-night)) .container { background-color: rgba(255, 255, 255, .88) !important; border-color: rgba(15, 23, 42, .12) !important; } .gradio-container:not(:has(#vc-mode-night)) button { background: rgba(255, 255, 255, .82) !important; border-color: rgba(8, 145, 178, .22) !important; } .gradio-container:not(:has(#vc-mode-night)) button.primary, .gradio-container:not(:has(#vc-mode-night)) #vc-message button:last-child { background: linear-gradient(135deg, #0891b2, #0f766e) !important; color: #ffffff !important; } .gradio-container:has(#vc-mode-night) .vc-appearance-mode, .gradio-container:has(#vc-mode-night) .vc-appearance-mode *, .gradio-container:has(#vc-mode-night) .vc-panel input, .gradio-container:has(#vc-mode-night) .vc-panel textarea, .gradio-container:has(#vc-mode-night) .vc-panel select { color: #e5e7eb !important; } """ VOICE_STYLE_CHOICES = [ ("跟随角色", "neutral"), ("温柔", "soft"), ("坚定", "firm"), ("开心", "happy"), ("担心", "concerned"), ("俏皮", "playful"), ] APPEARANCE_CHOICES = [("晨光舱", "day"), ("夜航舱", "night")] def _theme() -> gr.Theme: return gr.themes.Default(primary_hue="cyan", secondary_hue="amber", neutral_hue="zinc") def _appearance_marker(mode: str) -> str: marker_id = "vc-mode-night" if mode == "night" else "vc-mode-day" return f'' def _character_choices() -> list[tuple[str, str]]: return [ (character_display_name(character), character_id) for character_id, character in get_character_packages().items() ] def _voice_choices(character: dict) -> list[tuple[str, str]]: choices = character.get("voice_options") or [] if choices: return [(str(label), str(value)) for label, value in choices] voice = character.get("voice", {}) return [(voice.get("voice_label") or voice.get("voice_id") or "默认音色", voice.get("voice_id") or "default")] def _initial_state(character_id: str) -> dict: character = get_character(character_id) return { "character_id": character_id, "stage": {"expression": "idle", "motion": "breathe", "intensity": 0.35}, "events": [], "last_vision_note": None, "character": character, "voice": _default_voice_state(character, enabled=True), } def _default_voice_state(character: dict, enabled: bool) -> dict[str, Any]: voice = character.get("voice", {}) return { "enabled": enabled, "voice_id": voice.get("voice_id", "default"), "style": voice.get("default_style", "neutral"), "emotion": voice.get("default_style", "neutral"), "speed": _pace_to_speed(voice.get("pace", "normal")), "energy": float(voice.get("energy", 0.5)), "audio_prompt_path": voice.get("audio_prompt_path"), } def _pace_to_speed(pace: str) -> float: return {"slow": 0.92, "normal": 1.0, "fast": 1.08}.get(str(pace), 1.0) def _opening_history(character: dict) -> list[dict[str, str]]: first_mes = str(character.get("first_mes") or "").strip() if not first_mes: return [] return [{"role": "assistant", "content": first_mes}] def _history_for_model(history: list[dict], character: dict) -> list[dict]: history = list(history or []) first_mes = str(character.get("first_mes") or "").strip() if history and history[0].get("role") == "assistant" and str(history[0].get("content") or "").strip() == first_mes: return history[1:] return history def _character_header_html(character: dict) -> str: tags = "".join(f"{html.escape(str(tag))}" for tag in character.get("tags", [])[:6]) avatar_uri = _avatar_uri(character) name = html.escape(character_display_name(character)) summary = html.escape(character.get("summary", "")) return f"""
{name}
{name}
{tags}
{summary}
""" def _avatar_uri(character: dict) -> str: avatar = character.get("visual", {}).get("avatar", "star") for candidate in ( ROOT / "assets" / "characters" / avatar / "idle.png", ROOT / "assets" / "characters" / "star" / "idle.png", ): if candidate.exists(): encoded = base64.b64encode(candidate.read_bytes()).decode("ascii") return f"data:image/png;base64,{encoded}" return "" def switch_character(character_id: str): state = _initial_state(character_id) character = state["character"] voice = state["voice"] return ( _character_header_html(character), format_character_card_markdown(character), render_character_stage(character, state["stage"]), state, _opening_history(character), {"events": []}, gr.update(choices=_voice_choices(character), value=voice["voice_id"]), gr.update(value=voice["style"]), gr.update(value=voice["speed"]), gr.update(value=voice["energy"]), gr.update(value=True), "等待新的语音回复。", None, ) def refresh_model_status(): statuses = check_all_statuses() return statuses_markdown(statuses), statuses_json(statuses) def refresh_model_status_both(): statuses = check_all_statuses() html_status = statuses_markdown(statuses) note = _model_action_note(statuses) return html_status, html_status, statuses_json(statuses), note, note def refresh_workshop_status_only(): return statuses_markdown(check_all_statuses()) def start_main_model(): starting_status = llm_loading_status() statuses = statuses_with_llm_status(starting_status) html_status = statuses_markdown(statuses) note = "主模型启动请求已发出。首次加载可能需要 1-3 分钟;这个操作只预热当前服务,不会把 GPU 常驻。" yield html_status, html_status, statuses_json(statuses), note, note result = warm_llm_model() statuses = statuses_with_llm_status(result) html_status = statuses_markdown(statuses) note = _model_action_note(statuses, warmup=True) yield html_status, html_status, statuses_json(statuses), note, note def _initial_model_action_note() -> str: return "主模型按需启动。首次对话前可先启动模型;启动完成后几分钟内对话会更快。" def _model_action_note(statuses: list, *, warmup: bool = False) -> str: llm = next((status for status in statuses if status.kind == "llm"), None) if not llm: return _initial_model_action_note() if llm.state == "ready": prefix = "主模型已启动。" if warmup else "主模型可用。" return f"{prefix} 当前请求延迟约 {llm.latency_s:.1f}s。" if llm.latency_s is not None else prefix if llm.state == "loading": return llm.message or "主模型正在启动;稍后刷新状态。" if llm.state == "sleeping": return "主模型已休眠;可以点击启动主模型,或直接发送消息等待冷启动。" if llm.state == "mock": return "当前使用本地 mock,对话不会等待 Modal 模型。" if llm.state == "unconfigured": return "主模型 endpoint 未配置。" return llm.message or "主模型状态异常,请刷新状态或检查 endpoint。" def _hf_oauth_available() -> bool: if os.environ.get("SPACE_ID") or os.environ.get("HF_TOKEN"): return True try: from huggingface_hub import get_token return bool(get_token()) except Exception: return False def workshop_login_status(profile: gr.OAuthProfile | None = None) -> str: user = get_current_user(profile) if user.authenticated: return f"已登录 Hugging Face:{user.display_name}(@{user.username})。生成进度会保存到你的任务列表。" return "未登录。可以先填写或导入角色;点击生成、打包、安装前需要使用 Hugging Face 登录。" def workshop_refresh_runs(profile: gr.OAuthProfile | None = None): user = get_current_user(profile) choices = list_user_workshop_runs(user) value = choices[0][1] if choices else None return ( workshop_login_status(profile), gr.update(choices=choices, value=value), summarize_workshop_stats(), ) def workshop_load_selected_run(run_dir: str | None, profile: gr.OAuthProfile | None = None): try: user = get_current_user(profile) choices = list_user_workshop_runs(user) selected = run_dir or (choices[0][1] if choices else None) if not selected: return (*_empty_workshop_outputs("没有可恢复的角色生成任务。"), gr.update(choices=choices, value=None), summarize_workshop_stats()) state = load_workshop_run(selected, user=user) return (*_workshop_state_outputs(state, "已加载历史任务,可以从中断位置继续。"), gr.update(choices=choices, value=selected), summarize_workshop_stats()) except Exception as exc: user = get_current_user(profile) return (*_empty_workshop_outputs(f"加载任务失败:{exc}"), gr.update(choices=list_user_workshop_runs(user), value=run_dir), summarize_workshop_stats()) def workshop_load_recent_run(profile: gr.OAuthProfile | None = None): return workshop_load_selected_run(None, profile) def _workshop_state_outputs(state: dict, message: str): draft = state.get("draft") or {} expression_paths = [ state.get("expression_assets", {}).get(slot) for slot in ("idle", "listening", "thinking", "worried", "smile", "happy", "talk", "focus") if state.get("expression_assets", {}).get(slot) ] package_dir = Path(state.get("package_dir") or Path(state.get("run_dir") or "") / "package") grid_path = package_dir / "generated" / "asset_grid.png" preview_html = "" if grid_path.exists(): try: preview_html = render_packaged_stage_preview(state) except Exception: preview_html = "" selected = state.get("selected_candidate_index") selected_label = f"当前选择:{selected}" if selected is not None else "当前选择:无。" return ( message, format_character_card_markdown(draft) if draft else "", state, state.get("main_candidates") or [], selected if selected is not None else 0, selected_label, expression_paths, state.get("background_asset"), str(grid_path) if grid_path.exists() else None, preview_html, ) def _empty_workshop_outputs(message: str): return (message, "", {}, [], 0, "当前选择:无。", [], None, None, "") def _workshop_run_dropdown_update(profile: gr.OAuthProfile | None, state: dict | None = None): user = get_current_user(profile) choices = list_user_workshop_runs(user) value = (state or {}).get("run_dir") if not value and choices: value = choices[0][1] return gr.update(choices=choices, value=value) def workshop_create_from_form( display_name: str, description: str, personality: str, scenario: str, first_mes: str, tags: str, ): try: draft = create_draft_from_form( display_name=display_name, description=description, personality=personality, scenario=scenario, first_mes=first_mes, tags=tags, ) workshop_state = create_initial_state(draft, persist=False) return ( "草案已创建,可以生成主视觉候选。", format_character_card_markdown(draft), workshop_state, [], 0, "当前选择:尚未生成候选。", [], None, None, "", ) except Exception as exc: return (f"创建草案失败:{exc}", "", {}, [], 0, "当前选择:无。", [], None, None, "") def workshop_import_tavern(file): try: draft = create_draft_from_tavern_json(file) workshop_state = create_initial_state(draft, persist=False) return ( "Tavern JSON 已导入,可以生成主视觉候选。", format_character_card_markdown(draft), workshop_state, [], 0, "当前选择:尚未生成候选。", [], None, None, "", ) except Exception as exc: return (f"导入失败:{exc}", "", {}, [], 0, "当前选择:无。", [], None, None, "") def _require_workshop_model_ready() -> str | None: status = check_image_generation_status() if status.state == "ready": return None return status.message or "Modal 图像生成服务可能已休眠或正在冷启动,请等待容器启动和模型载入后重试。" def workshop_generate_main_candidates(workshop_state: dict | None, profile: gr.OAuthProfile | None = None): started = time.perf_counter() user = get_current_user(profile) try: user = require_login_for_generation(profile) except ValueError as exc: record_workshop_event(user, "generate_main_candidates", {"stage": "auth_required", "success": False, "failure_reason": str(exc)}) return str(exc), gr.update(), workshop_state or {}, "当前选择:无。", _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() wait_message = _require_workshop_model_ready() if wait_message: record_workshop_event(user, "generate_main_candidates", {"stage": "modal_wait", "success": False, "failure_reason": wait_message, "modal_state": "not_ready"}) return wait_message, gr.update(), workshop_state or {}, "当前选择:无。", _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() try: workshop_state = ensure_user_workshop_run(workshop_state or {}, user) workshop_state = generate_main_candidates(workshop_state) paths = workshop_state.get("main_candidates") or [] record_workshop_event( user, "generate_main_candidates", { "stage": "main_candidates", "character_id": workshop_state.get("character_id"), "duration_seconds": round(time.perf_counter() - started, 3), "success": True, "image_count": len(paths), }, ) return "主视觉候选已生成。请选择其中一张。", paths, workshop_state, "当前选择:0", _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() except Exception as exc: record_workshop_event(user, "generate_main_candidates", {"stage": "main_candidates", "success": False, "failure_reason": str(exc), "duration_seconds": round(time.perf_counter() - started, 3)}) return f"主视觉生成失败:{exc}", gr.update(), workshop_state or {}, "当前选择:无。", _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() def workshop_select_candidate(workshop_state: dict | None, evt: gr.SelectData): try: index = int(evt.index if evt is not None else 0) workshop_state = select_main_candidate(workshop_state or {}, index) return workshop_state, f"当前选择:{index}" except Exception as exc: return workshop_state or {}, f"选择失败:{exc}" def workshop_generate_assets(workshop_state: dict | None, profile: gr.OAuthProfile | None = None): started = time.perf_counter() user = get_current_user(profile) try: user = require_login_for_generation(profile) except ValueError as exc: record_workshop_event(user, "generate_expression_pack", {"stage": "auth_required", "success": False, "failure_reason": str(exc)}) return str(exc), gr.update(), None, workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() wait_message = _require_workshop_model_ready() if wait_message: record_workshop_event(user, "generate_expression_pack", {"stage": "modal_wait", "success": False, "failure_reason": wait_message, "modal_state": "not_ready"}) return wait_message, gr.update(), None, workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() try: workshop_state = ensure_user_workshop_run(workshop_state or {}, user) if len(workshop_state.get("main_candidates") or []) < 4: return "请先生成 4 张主视觉候选,再继续生成 8 表情和背景。", gr.update(), None, workshop_state, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() workshop_state = generate_expression_pack(workshop_state) workshop_state = generate_background(workshop_state) expression_paths = [workshop_state["expression_assets"][slot] for slot in ("idle", "listening", "thinking", "worried", "smile", "happy", "talk", "focus")] record_workshop_event( user, "generate_expression_pack", { "stage": "assets_ready", "character_id": workshop_state.get("character_id"), "duration_seconds": round(time.perf_counter() - started, 3), "success": True, "image_count": len(expression_paths) + (1 if workshop_state.get("background_asset") else 0), }, ) return "8 表情和背景已生成,可以开始去背景并打包。", expression_paths, workshop_state.get("background_asset"), workshop_state, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() except Exception as exc: record_workshop_event(user, "generate_expression_pack", {"stage": "assets_ready", "success": False, "failure_reason": str(exc), "duration_seconds": round(time.perf_counter() - started, 3)}) return f"表情或背景生成失败:{exc}", gr.update(), None, workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() def workshop_package_assets(workshop_state: dict | None, profile: gr.OAuthProfile | None = None): started = time.perf_counter() user = get_current_user(profile) try: user = require_login_for_generation(profile) except ValueError as exc: record_workshop_event(user, "package_assets", {"stage": "auth_required", "success": False, "failure_reason": str(exc)}) return str(exc), None, "", workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() try: workshop_state = ensure_user_workshop_run(workshop_state or {}, user) workshop_state = matte_and_package_assets(workshop_state) package_dir = Path(workshop_state["package_dir"]) grid_path = package_dir / "generated" / "asset_grid.png" preview_html = render_packaged_stage_preview(workshop_state) record_workshop_event(user, "package_assets", {"stage": "packaged", "character_id": workshop_state.get("character_id"), "duration_seconds": round(time.perf_counter() - started, 3), "success": True}) return "角色资产已打包,可预览后安装。", str(grid_path), preview_html, workshop_state, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() except Exception as exc: record_workshop_event(user, "package_assets", {"stage": "packaged", "success": False, "failure_reason": str(exc), "duration_seconds": round(time.perf_counter() - started, 3)}) return f"打包失败:{exc}", None, "", workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats() def workshop_install_character(workshop_state: dict | None, profile: gr.OAuthProfile | None = None): started = time.perf_counter() user = get_current_user(profile) try: user = require_login_for_generation(profile) except ValueError as exc: record_workshop_event(user, "install_character", {"stage": "auth_required", "success": False, "failure_reason": str(exc)}) return ( str(exc), gr.update(choices=_character_choices()), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats(), ) try: workshop_state = ensure_user_workshop_run(workshop_state or {}, user) workshop_state = install_character_package(workshop_state) character_id = workshop_state["installed_character_id"] switch_values = switch_character(character_id) record_workshop_event(user, "install_character", {"stage": "installed", "character_id": character_id, "duration_seconds": round(time.perf_counter() - started, 3), "success": True}) return ( f"角色已安装:{character_id}", gr.update(choices=_character_choices(), value=character_id), *switch_values, workshop_state, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats(), ) except Exception as exc: record_workshop_event(user, "install_character", {"stage": "installed", "success": False, "failure_reason": str(exc), "duration_seconds": round(time.perf_counter() - started, 3)}) return ( f"安装失败:{exc}", gr.update(choices=_character_choices()), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats(), ) def _parse_message(message: Any) -> tuple[str, dict[str, list[dict[str, Any]]], str]: if message is None: return "", {"images": []}, "" if isinstance(message, str): text = message.strip() return text, {"images": []}, text text = str(message.get("text") or "").strip() if isinstance(message, dict) else "" files = message.get("files") if isinstance(message, dict) else [] media_inputs: dict[str, list[dict[str, Any]]] = {"images": []} for item in files or []: parsed = _parse_file_item(item) if not parsed: continue if parsed["kind"] == "image": media_inputs["images"].append(parsed) if not text: if media_inputs["images"]: text = "请看这张图片,并用你的角色视角回应。" attachment_labels = [] if media_inputs["images"]: attachment_labels.append(f"{len(media_inputs['images'])} 张图片") display_text = text if attachment_labels: display_text = f"{text}\n\n(已附加:{','.join(attachment_labels)})" return text, media_inputs, display_text def _parse_file_item(item: Any) -> dict[str, Any] | None: if isinstance(item, str): path = item mime_type = mimetypes.guess_type(path)[0] or "" name = Path(path).name elif isinstance(item, dict): path = item.get("path") or item.get("name") or item.get("orig_name") mime_type = item.get("mime_type") or mimetypes.guess_type(str(path or ""))[0] or "" name = item.get("orig_name") or item.get("name") or Path(str(path or "")).name else: path = getattr(item, "path", None) or getattr(item, "name", None) mime_type = getattr(item, "mime_type", None) or mimetypes.guess_type(str(path or ""))[0] or "" name = getattr(item, "orig_name", None) or Path(str(path or "")).name if not path: return None suffix = Path(str(path)).suffix.lower() if mime_type.startswith("image/") or suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}: kind = "image" else: return None return {"kind": kind, "path": str(path), "mime_type": mime_type, "name": str(name)} def chat( message: Any, history: list[dict] | None, state: dict | None, voice_id: str, voice_style: str, voice_speed: float, voice_energy: float, voice_enabled: bool, ): if not state: state = _initial_state("star_knight") character = state.get("character") or get_character(state.get("character_id", "star_knight")) user_text, media_inputs, display_text = _parse_message(message) history = list(history or []) if not user_text and not media_inputs["images"]: yield history, render_character_stage(character, state["stage"]), None, "等待输入。", _debug_state(state), state return voice_state = { **_default_voice_state(character, enabled=voice_enabled), "voice_id": voice_id, "style": voice_style, "emotion": voice_style, "speed": float(voice_speed or 1.0), "energy": float(voice_energy or 0.5), "enabled": bool(voice_enabled), } state["voice"] = voice_state state["last_vision_note"] = _media_note(media_inputs) model_history = _history_for_model(history, character) history = history + [{"role": "user", "content": display_text}, {"role": "assistant", "content": _assistant_wait_message()}] partial = "" audio_value = None audio_status = _initial_audio_status(voice_state) yield ( history, render_character_stage(character, state["stage"]), audio_value, audio_status, _debug_state(state), state, ) for event in stream_reply( user_text=user_text, history=model_history, state=state, media_inputs=media_inputs, voice_state=voice_state, ): state.setdefault("events", []).append(event) state["events"] = state["events"][-100:] if event["type"] == "stage": state["stage"] = {**state.get("stage", {}), **event} elif event["type"] == "text_delta": partial += event.get("text", "") history[-1]["content"] = partial elif event["type"] == "audio": audio_value = event.get("path") audio_status = "语音回复已生成,可点击播放器收听。" elif event["type"] == "error": audio_status = event.get("message", audio_status) yield ( history, render_character_stage(character, state["stage"]), audio_value, audio_status, _debug_state(state), state, ) if not audio_value: audio_status = _final_audio_status(voice_state) yield history, render_character_stage(character, state["stage"]), audio_value, audio_status, _debug_state(state), state def _media_note(media_inputs: dict[str, list[dict[str, Any]]]) -> str | None: parts = [] if media_inputs.get("images"): parts.append(f"用户本轮附加了 {len(media_inputs['images'])} 张图片。") return " ".join(parts) if parts else None def _initial_audio_status(voice_state: dict[str, Any]) -> str: if not voice_state.get("enabled", True): return "语音生成已关闭。" if not os.environ.get("VC_MODAL_TTS_URL"): if _local_tts_service_exists(): return "TTS endpoint 未绑定;modal_apps/modal_tts.py 已存在,部署后设置 VC_MODAL_TTS_URL 即可生成语音。" return "语音模型未配置。" return "正在等待语音回复。" def _final_audio_status(voice_state: dict[str, Any]) -> str: if not voice_state.get("enabled", True): return "语音生成已关闭。" if not os.environ.get("VC_MODAL_TTS_URL"): if _local_tts_service_exists(): return "TTS endpoint 未绑定;modal_apps/modal_tts.py 已存在,部署后设置 VC_MODAL_TTS_URL 即可生成语音。" return "语音模型未配置。" return "本轮没有生成可播放语音。" def _assistant_wait_message() -> str: if os.environ.get("VC_USE_MOCK") == "1": return "正在生成回复..." return "正在连接主模型。如果服务刚休眠,会先完成冷启动和权重加载。" def _local_tts_service_exists() -> bool: return (ROOT / "modal_apps" / "modal_tts.py").exists() def _debug_state(state: dict) -> dict[str, Any]: return { "character_id": state.get("character_id"), "stage": state.get("stage"), "voice": state.get("voice"), "last_vision_note": state.get("last_vision_note"), "events": state.get("events", [])[-25:], } def build_demo() -> gr.Blocks: default_id = "star_knight" default_state = _initial_state(default_id) default_character = default_state["character"] default_voice = default_state["voice"] with gr.Blocks(title="Virtual Characters", elem_id="vc-root", theme=_theme(), css=APP_CSS) as demo: state = gr.State(default_state) workshop_state = gr.State({}) appearance_marker = gr.HTML(value=_appearance_marker("day"), visible=True) with gr.Tabs(elem_classes=["vc-tabs"]): with gr.Tab("对话"): with gr.Row(elem_classes=["vc-topbar"]): with gr.Column(scale=1, min_width=320, elem_classes=["vc-status-wrap"]): model_status = gr.HTML(value=statuses_markdown(initial_model_statuses()), elem_classes=["vc-panel"]) with gr.Column(scale=0, min_width=240, elem_classes=["vc-status-actions"]): appearance_mode = gr.Radio( choices=APPEARANCE_CHOICES, value="day", label="视觉模式", elem_classes=["vc-appearance-mode"], ) start_model = gr.Button("启动主模型", variant="primary") refresh_status = gr.Button("刷新模型状态", variant="secondary") model_action_status = gr.Markdown(value=_initial_model_action_note(), elem_classes=["vc-model-action"]) with gr.Row(equal_height=True): with gr.Column(scale=1, min_width=300, elem_classes=["vc-panel", "vc-left"]): character_select = gr.Radio( choices=_character_choices(), value=default_id, label="角色", ) character_header = gr.HTML(value=_character_header_html(default_character)) character_card = gr.Markdown( value=format_character_card_markdown(default_character), elem_classes=["vc-character-card"], ) with gr.Column(scale=3, min_width=430, elem_classes=["vc-stage-col"]): stage = gr.HTML( value=render_character_stage(default_character, default_state["stage"]), elem_id="character-stage", ) with gr.Row(elem_classes=["vc-input-dock"]): message_input = gr.MultimodalTextbox( label="输入", placeholder="输入文字,也可以附加图片...", sources=["upload"], file_types=["image"], file_count="multiple", submit_btn="发送", stop_btn=True, elem_id="vc-message", ) with gr.Column(scale=1, min_width=340, elem_classes=["vc-panel", "vc-output", "vc-right"]): chatbot = gr.Chatbot( label="输出", value=_opening_history(default_character), height=430, ) audio = gr.Audio(label="语音回复", autoplay=False, interactive=False, elem_id="voice-output") audio_status = gr.Markdown(value="等待新的语音回复。", elem_classes=["vc-audio-status"]) with gr.Accordion("语音控制", open=False): voice_enabled = gr.Checkbox(value=True, label="生成语音回复") voice_id = gr.Dropdown( choices=_voice_choices(default_character), value=default_voice["voice_id"], label="音色", ) voice_style = gr.Dropdown( choices=VOICE_STYLE_CHOICES, value=default_voice["style"], label="语气", ) voice_speed = gr.Slider(0.75, 1.25, value=default_voice["speed"], step=0.01, label="语速") voice_energy = gr.Slider(0.2, 1.0, value=default_voice["energy"], step=0.05, label="表现力") with gr.Accordion("事件与状态调试", open=False): with gr.Row(): debug = gr.JSON(value={"events": []}, label="事件流") debug_models = gr.JSON(value=statuses_json(initial_model_statuses()), label="模型状态") debug_workshop_stats = gr.JSON(value=summarize_workshop_stats(), label="角色工坊统计") with gr.Tab("角色工坊"): with gr.Column(elem_id="vc-workshop-shell", elem_classes=["vc-workshop-shell"]): with gr.Row(elem_classes=["vc-topbar"]): with gr.Column(scale=1, min_width=320, elem_classes=["vc-status-wrap"]): workshop_model_status = gr.HTML(value=statuses_markdown(initial_model_statuses()), elem_classes=["vc-panel"]) with gr.Column(scale=0, min_width=240, elem_classes=["vc-status-actions"]): if _hf_oauth_available(): workshop_login = gr.LoginButton("使用 Hugging Face 登录") else: workshop_login = None gr.Markdown( "本地 OAuth 预演需要先运行 `hf auth login` 或设置 `HF_TOKEN`,重启后这里会出现“使用 Hugging Face 登录”按钮;Space 上用户会直接点击按钮登录。", elem_classes=["vc-model-action"], ) workshop_start_model = gr.Button("启动主模型", variant="primary") workshop_refresh_status = gr.Button("刷新模型状态", variant="secondary") workshop_model_action_status = gr.Markdown(value=_initial_model_action_note(), elem_classes=["vc-model-action"]) with gr.Row(equal_height=True): with gr.Column(scale=1, min_width=360, elem_classes=["vc-panel", "vc-workshop-grid"]): workshop_user_status = gr.Markdown(value=workshop_login_status(), elem_classes=["vc-workshop-status"]) with gr.Column(scale=2, min_width=460, elem_classes=["vc-panel", "vc-workshop-grid"]): workshop_run_choice = gr.Dropdown( choices=list_user_workshop_runs(get_current_user(None)), value=None, label="我的生成任务", interactive=True, ) with gr.Row(): refresh_workshop_runs = gr.Button("刷新任务列表", variant="secondary") load_workshop_run_button = gr.Button("加载任务继续", variant="secondary") load_recent_workshop_run = gr.Button("加载最近任务", variant="secondary") with gr.Row(equal_height=True): with gr.Column(scale=1, min_width=360, elem_classes=["vc-panel", "vc-workshop-grid"]): workshop_status = gr.Markdown(value="先导入 Tavern JSON,或手填角色设定创建草案。", elem_classes=["vc-workshop-status"]) tavern_file = gr.File(label="Tavern JSON 角色卡", file_types=[".json"]) import_tavern = gr.Button("导入 Tavern JSON", variant="secondary") gr.Markdown("### 手填角色设定") workshop_name = gr.Textbox(label="角色名", value="星核") workshop_description = gr.Textbox(label="描述", lines=4, value="一名银白短发、青绿色眼睛的原创科幻通讯员。") workshop_personality = gr.Textbox(label="性格", lines=3, value="冷静、温柔、边界清晰") workshop_scenario = gr.Textbox(label="场景", lines=3, value="用户正在通过虚拟通讯端与角色对话。") workshop_first_mes = gr.Textbox(label="开场白", lines=2, value="我在。现在频道很稳定。") workshop_tags = gr.Textbox(label="标签", value="原创, 科幻, 通讯端") create_draft = gr.Button("创建草案", variant="primary") workshop_draft_card = gr.Markdown(value="", elem_classes=["vc-character-card"]) with gr.Column(scale=2, min_width=460, elem_classes=["vc-panel", "vc-workshop-grid"]): with gr.Row(): generate_candidates = gr.Button("生成 4 张主视觉候选", variant="primary") generate_assets = gr.Button("生成 8 表情和背景", variant="secondary") selected_candidate_index = gr.Number(value=0, visible=False) selected_candidate_label = gr.Markdown(value="当前选择:无。", elem_classes=["vc-workshop-status"]) main_gallery = gr.Gallery(label="主视觉候选", columns=4, height=260, object_fit="contain") expression_gallery = gr.Gallery(label="8 表情/动作独立图片", columns=4, height=360, object_fit="contain") background_preview = gr.Image(label="背景图", type="filepath", height=180) with gr.Column(scale=1, min_width=360, elem_classes=["vc-panel", "vc-workshop-grid"]): package_assets = gr.Button("去背景并打包预览", variant="secondary") package_grid = gr.Image(label="资产包网格", type="filepath", height=300) package_stage_preview = gr.HTML(value="") install_character = gr.Button("安装并切换到新角色", variant="primary") refresh_status.click( refresh_model_status_both, outputs=[model_status, workshop_model_status, debug_models, model_action_status, workshop_model_action_status], show_progress="hidden", ) start_model.click( start_main_model, outputs=[model_status, workshop_model_status, debug_models, model_action_status, workshop_model_action_status], show_progress="minimal", ) workshop_refresh_status.click( refresh_model_status_both, outputs=[model_status, workshop_model_status, debug_models, model_action_status, workshop_model_action_status], show_progress="hidden", ) workshop_start_model.click( start_main_model, outputs=[model_status, workshop_model_status, debug_models, model_action_status, workshop_model_action_status], show_progress="minimal", ) if workshop_login is not None: workshop_login.click( workshop_refresh_runs, outputs=[workshop_user_status, workshop_run_choice, debug_workshop_stats], show_progress="hidden", ) refresh_workshop_runs.click( workshop_refresh_runs, outputs=[workshop_user_status, workshop_run_choice, debug_workshop_stats], show_progress="hidden", ) load_workshop_run_button.click( workshop_load_selected_run, inputs=[workshop_run_choice], outputs=[ workshop_status, workshop_draft_card, workshop_state, main_gallery, selected_candidate_index, selected_candidate_label, expression_gallery, background_preview, package_grid, package_stage_preview, workshop_run_choice, debug_workshop_stats, ], show_progress="minimal", ) load_recent_workshop_run.click( workshop_load_recent_run, outputs=[ workshop_status, workshop_draft_card, workshop_state, main_gallery, selected_candidate_index, selected_candidate_label, expression_gallery, background_preview, package_grid, package_stage_preview, workshop_run_choice, debug_workshop_stats, ], show_progress="minimal", ) appearance_mode.change( _appearance_marker, inputs=[appearance_mode], outputs=[appearance_marker], show_progress="hidden", ) character_select.change( switch_character, inputs=[character_select], outputs=[ character_header, character_card, stage, state, chatbot, debug, voice_id, voice_style, voice_speed, voice_energy, voice_enabled, audio_status, audio, ], show_progress="hidden", ) message_input.submit( chat, inputs=[ message_input, chatbot, state, voice_id, voice_style, voice_speed, voice_energy, voice_enabled, ], outputs=[chatbot, stage, audio, audio_status, debug, state], show_progress="hidden", ).then(lambda: None, outputs=[message_input], show_progress="hidden") create_draft.click( workshop_create_from_form, inputs=[ workshop_name, workshop_description, workshop_personality, workshop_scenario, workshop_first_mes, workshop_tags, ], outputs=[ workshop_status, workshop_draft_card, workshop_state, main_gallery, selected_candidate_index, selected_candidate_label, expression_gallery, background_preview, package_grid, package_stage_preview, ], show_progress="hidden", ) import_tavern.click( workshop_import_tavern, inputs=[tavern_file], outputs=[ workshop_status, workshop_draft_card, workshop_state, main_gallery, selected_candidate_index, selected_candidate_label, expression_gallery, background_preview, package_grid, package_stage_preview, ], show_progress="hidden", ) generate_candidates.click( workshop_generate_main_candidates, inputs=[workshop_state], outputs=[workshop_status, main_gallery, workshop_state, selected_candidate_label, workshop_run_choice, debug_workshop_stats], show_progress="full", show_progress_on=main_gallery, concurrency_limit=1, concurrency_id="character_workshop_generation", ) main_gallery.select( workshop_select_candidate, inputs=[workshop_state], outputs=[workshop_state, selected_candidate_label], show_progress="hidden", ) generate_assets.click( workshop_generate_assets, inputs=[workshop_state], outputs=[workshop_status, expression_gallery, background_preview, workshop_state, workshop_run_choice, debug_workshop_stats], show_progress="full", show_progress_on=[expression_gallery, background_preview], concurrency_limit=1, concurrency_id="character_workshop_generation", ) package_assets.click( workshop_package_assets, inputs=[workshop_state], outputs=[workshop_status, package_grid, package_stage_preview, workshop_state, workshop_run_choice, debug_workshop_stats], show_progress="full", show_progress_on=[package_grid, package_stage_preview], concurrency_limit=1, concurrency_id="character_workshop_generation", ) install_character.click( workshop_install_character, inputs=[workshop_state], outputs=[ workshop_status, character_select, character_header, character_card, stage, state, chatbot, debug, voice_id, voice_style, voice_speed, voice_energy, voice_enabled, audio_status, audio, workshop_state, workshop_run_choice, debug_workshop_stats, ], show_progress="minimal", concurrency_limit=1, concurrency_id="character_workshop_generation", ) return demo # When running inside a Space, create the demo and call `.queue()` to # ensure the Gradio background queue keeps the process active for the # Spaces runtime to serve the app. # demo = build_demo().queue() if os.environ.get("SPACE_ID") else None # # Expose `app` name as an alias — some deploy tooling looks for `app`. # app = demo def launch_app(*, prevent_thread_lock: bool = False): app_demo = demo if demo is not None else build_demo().queue() # When running inside a Hugging Face Space, bind to 0.0.0.0 so the # platform can route traffic to the container. Allow overriding via # `VC_GRADIO_SERVER_NAME` for local development. default_host = "0.0.0.0" if os.environ.get("SPACE_ID") else "127.0.0.1" app_demo.launch( server_name=os.environ.get("VC_GRADIO_SERVER_NAME", default_host), server_port=int(os.environ.get("VC_GRADIO_PORT", "7861")), prevent_thread_lock=prevent_thread_lock, ) return app_demo def _keep_alive_until_interrupt(demo) -> None: try: while True: time.sleep(3600) except KeyboardInterrupt: demo.close() def main(): demo = build_demo() demo.queue() demo.launch() if __name__ == "__main__": main()