| 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'<span id="{marker_id}" aria-hidden="true" style="display:none"></span>' |
|
|
|
|
| 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"<span>{html.escape(str(tag))}</span>" 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""" |
| <div class="vc-card-head"> |
| <img src="{avatar_uri}" alt="{name}" /> |
| <div class="vc-card-title"> |
| <strong>{name}</strong> |
| <div class="vc-tags">{tags}</div> |
| <small>{summary}</small> |
| </div> |
| </div> |
| """ |
|
|
|
|
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
|
|
| def launch_app(*, prevent_thread_lock: bool = False): |
| app_demo = demo if demo is not None else build_demo().queue() |
| |
| |
| |
| 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() |
|
|