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"""
"""
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()