Files
LittleWhiteBox/modules/novel-draw/novel-draw.html

1136 lines
38 KiB
HTML
Raw Normal View History

2025-12-19 02:19:10 +08:00
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Novel 画图设置</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-primary: #0f1419;
--bg-secondary: #1a2332;
--bg-tertiary: #0a0e14;
--bg-card: rgba(26, 35, 50, 0.6);
--bg-input: rgba(0, 0, 0, 0.35);
--text-primary: #e7e9ea;
--text-secondary: #8b98a5;
--text-muted: #5c6b7a;
--border-color: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.15);
--accent: #d4a574;
--accent-hover: #e6b980;
--accent-soft: rgba(212, 165, 116, 0.12);
--accent-glow: rgba(212, 165, 116, 0.25);
--success: #3ecf8e;
--warning: #f0b429;
--danger: #f87171;
--danger-soft: rgba(248, 113, 113, 0.12);
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
overflow-x: hidden;
overflow-y: auto;
line-height: 1.5;
}
.nd-container {
min-height: 100%;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 1400px;
margin: 0 auto;
}
/* Header */
.nd-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
padding: 20px 24px;
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
border: 1px solid var(--border-color);
border-radius: 16px;
flex-wrap: wrap;
}
.nd-header-left { flex: 1; min-width: 240px; }
.nd-title {
font-size: 1.375rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
letter-spacing: -0.02em;
}
.nd-title i { color: var(--accent); }
.nd-subtitle {
margin-top: 8px;
font-size: 0.875rem;
color: var(--text-secondary);
}
.nd-header-right {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.nd-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 999px;
font-size: 0.875rem;
font-weight: 600;
}
.nd-badge.enabled {
color: var(--success);
border-color: rgba(62, 207, 142, 0.3);
background: rgba(62, 207, 142, 0.1);
}
.nd-badge.disabled { color: var(--text-muted); }
/* 卡片 */
.nd-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.nd-card {
width: 100%;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
overflow: hidden;
backdrop-filter: blur(10px);
}
.nd-card-header {
padding: 16px 20px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
transition: background 0.2s;
}
.nd-card-header:hover { background: rgba(255, 255, 255, 0.04); }
.nd-card-title {
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.nd-card-title i { color: var(--accent); font-size: 0.9375rem; }
.nd-card-hint {
font-size: 0.8125rem;
color: var(--text-muted);
margin-left: auto;
margin-right: 12px;
}
.nd-card-chevron { color: var(--text-muted); transition: transform 0.25s ease; }
.nd-card.collapsed .nd-card-chevron { transform: rotate(-90deg); }
.nd-card.collapsed .nd-card-body { display: none; }
.nd-card-body { padding: 20px; }
/* 表单 */
.nd-row {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
margin-bottom: 16px;
}
.nd-row:last-child { margin-bottom: 0; }
@media (min-width: 640px) {
.nd-row.cols-2 { grid-template-columns: repeat(2, 1fr); }
.nd-row.cols-3 { grid-template-columns: repeat(3, 1fr); }
.nd-row.cols-4 { grid-template-columns: repeat(4, 1fr); }
.nd-row.cols-5 { grid-template-columns: repeat(5, 1fr); }
}
.nd-field { display: flex; flex-direction: column; gap: 8px; }
.nd-field label {
font-size: 0.8125rem;
color: var(--text-secondary);
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
}
.nd-field label .nd-label-hint {
font-weight: 400;
color: var(--text-muted);
font-size: 0.75rem;
}
.nd-input, .nd-select, .nd-textarea {
width: 100%;
padding: 12px 16px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 10px;
color: var(--text-primary);
font-size: 0.875rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.nd-input:focus, .nd-select:focus, .nd-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.nd-textarea {
min-height: 100px;
resize: vertical;
font-family: 'SF Mono', Monaco, Consolas, monospace;
line-height: 1.6;
}
.nd-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b98a5' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 14px center;
padding-right: 40px;
}
.nd-help {
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.5;
margin-top: 4px;
}
.nd-custom-wrap { display: none; margin-top: 10px; }
.nd-custom-wrap.visible { display: block; }
.nd-divider {
height: 1px;
background: var(--border-color);
margin: 20px 0;
}
.nd-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 14px;
}
/* 预设行:下拉 + 按钮组 */
.nd-preset-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.nd-preset-row .nd-select {
flex: 1;
min-width: 180px;
max-width: 300px;
}
.nd-preset-row .nd-btn-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* Pill 开关 */
.nd-pills {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.nd-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 10px;
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.nd-pill:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--border-hover);
}
.nd-pill input { display: none; }
.nd-pill input:checked + span { color: var(--accent); font-weight: 600; }
.nd-pill:has(input:checked) {
border-color: var(--accent);
background: var(--accent-soft);
box-shadow: 0 0 12px var(--accent-glow);
}
/* 按钮 - 统一风格 */
.nd-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 10px;
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.nd-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--border-hover);
}
.nd-btn:active { transform: scale(0.98); }
.nd-btn i { font-size: 0.8125rem; }
.nd-btn-danger:hover {
background: var(--danger-soft);
border-color: rgba(248, 113, 113, 0.3);
color: var(--danger);
}
.nd-btn-close {
width: 40px;
height: 40px;
padding: 0;
border-radius: 50%;
font-size: 1rem;
}
/* Radio 模式选择 */
.nd-radio-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.nd-radio {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 18px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.nd-radio:hover {
background: rgba(255, 255, 255, 0.06);
border-color: var(--border-hover);
}
.nd-radio input { display: none; }
.nd-radio-dot {
width: 18px;
height: 18px;
border: 2px solid var(--text-muted);
border-radius: 50%;
position: relative;
transition: all 0.2s;
}
.nd-radio input:checked ~ .nd-radio-dot {
border-color: var(--accent);
}
.nd-radio input:checked ~ .nd-radio-dot::after {
content: '';
position: absolute;
inset: 3px;
background: var(--accent);
border-radius: 50%;
}
.nd-radio:has(input:checked) {
border-color: var(--accent);
background: var(--accent-soft);
}
.nd-radio-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.nd-radio-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.nd-radio-hint {
font-size: 0.75rem;
color: var(--text-muted);
}
/* 密码输入框 */
.nd-password-wrap {
position: relative;
display: flex;
align-items: center;
}
.nd-password-wrap .nd-input {
padding-right: 44px;
}
.nd-password-toggle {
position: absolute;
right: 12px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
font-size: 0.875rem;
transition: color 0.2s;
}
.nd-password-toggle:hover {
color: var(--text-primary);
}
/* 操作栏 */
.nd-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
}
.nd-actions .nd-btn {
min-width: 100px;
}
.nd-status {
margin-left: auto;
font-size: 0.875rem;
color: var(--text-muted);
min-height: 1.2em;
display: flex;
align-items: center;
gap: 8px;
}
.nd-status.loading { color: var(--warning); }
.nd-status.loading::before {
content: '';
width: 14px;
height: 14px;
border: 2px solid var(--warning);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.nd-status.success { color: var(--success); }
.nd-status.error { color: var(--danger); }
@keyframes spin { to { transform: rotate(360deg); } }
.nd-preview {
margin-top: 20px;
padding: 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
display: none;
}
.nd-preview.visible { display: block; }
.nd-preview img {
width: 100%;
max-height: 500px;
object-fit: contain;
border-radius: 8px;
}
/* 提示框 */
.nd-tip {
padding: 12px 16px;
background: rgba(212, 165, 116, 0.08);
border: 1px solid rgba(212, 165, 116, 0.2);
border-radius: 10px;
font-size: 0.8125rem;
color: var(--text-secondary);
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: 16px;
}
.nd-tip i {
color: var(--accent);
flex-shrink: 0;
margin-top: 2px;
}
/* 滚动条 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.18); }
/* 移动端 */
@media (max-width: 640px) {
.nd-container { padding: 12px; }
.nd-header { padding: 16px; flex-direction: column; align-items: stretch; }
.nd-header-right { justify-content: space-between; }
.nd-card-body { padding: 16px; }
.nd-title { font-size: 1.2rem; }
.nd-preset-row { flex-direction: column; align-items: stretch; }
.nd-preset-row .nd-select { max-width: none; }
.nd-preset-row .nd-btn-group { justify-content: flex-start; }
.nd-actions { flex-direction: column; align-items: stretch; }
.nd-actions .nd-btn { justify-content: center; }
.nd-status { margin: 10px 0 0; justify-content: center; }
.nd-radio-group { flex-direction: column; }
}
</style>
</head>
<body>
<div class="nd-container">
<!-- Header -->
<header class="nd-header">
<div class="nd-header-left">
<div class="nd-title">
<i class="fa-solid fa-palette"></i>
Novel 画图设置
</div>
<div class="nd-subtitle">配置画图预设与生成参数</div>
</div>
<div class="nd-header-right">
<div class="nd-badge disabled" id="nd_enabled_state">
<i class="fa-solid fa-circle"></i>
<span>未启用</span>
</div>
<select id="nd_mode" class="nd-select" style="width: auto; min-width: 130px;">
<option value="manual">手动模式</option>
<option value="auto">自动模式</option>
</select>
<button id="nd_close" class="nd-btn nd-btn-close" title="关闭">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</header>
<div class="nd-cards">
<!-- API 设置 -->
<div class="nd-card">
<div class="nd-card-header">
<div class="nd-card-title"><i class="fa-solid fa-plug"></i> API 设置</div>
<i class="fa-solid fa-chevron-down nd-card-chevron"></i>
</div>
<div class="nd-card-body">
<div class="nd-section-title">连接模式</div>
<div class="nd-radio-group">
<label class="nd-radio">
<input type="radio" name="api_mode" value="tavern" checked>
<span class="nd-radio-dot"></span>
<div class="nd-radio-content">
<span class="nd-radio-label">酒馆后端</span>
<span class="nd-radio-hint">请求经由酒馆服务器转发,适合云端部署</span>
</div>
</label>
<label class="nd-radio">
<input type="radio" name="api_mode" value="direct">
<span class="nd-radio-dot"></span>
<div class="nd-radio-content">
<span class="nd-radio-label">官网直连</span>
<span class="nd-radio-hint">浏览器直接请求 NovelAI需本机能访问</span>
</div>
</label>
</div>
<div class="nd-divider"></div>
<div class="nd-section-title">API Key</div>
<div class="nd-field">
<label>NovelAI API Key</label>
<div class="nd-password-wrap">
<input id="nd_api_key" class="nd-input" type="password" placeholder="输入你的 NovelAI API Key">
<button id="nd_toggle_key" class="nd-password-toggle" type="button">
<i class="fa-solid fa-eye"></i>
</button>
</div>
<div class="nd-help">可在 NovelAI 账号设置中获取。酒馆模式下会自动同步到酒馆全局配置。</div>
</div>
<div class="nd-tip">
<i class="fa-solid fa-circle-info"></i>
<div>
<strong>酒馆后端</strong>:请求走酒馆服务器的网络环境,云用户推荐使用。<br>
<strong>官网直连</strong>:请求走你本机浏览器的网络,需要能访问 NovelAI科学上网
</div>
</div>
<div class="nd-actions" style="border-top: none; padding-top: 0; margin-top: 16px;">
<button id="nd_save_api" class="nd-btn"><i class="fa-solid fa-floppy-disk"></i> 保存设置</button>
<button id="nd_test_api" class="nd-btn"><i class="fa-solid fa-plug-circle-check"></i> 测试连接</button>
<span id="nd_api_status" class="nd-status"></span>
</div>
</div>
</div>
<!-- 预设管理 -->
<div class="nd-card">
<div class="nd-card-header">
<div class="nd-card-title"><i class="fa-solid fa-bookmark"></i> 预设管理</div>
<i class="fa-solid fa-chevron-down nd-card-chevron"></i>
</div>
<div class="nd-card-body">
<!-- 画图预设 -->
<div class="nd-section-title">画图预设</div>
<div class="nd-preset-row">
<select id="nd_preset" class="nd-select"></select>
<div class="nd-btn-group">
<button id="nd_preset_add" class="nd-btn"><i class="fa-solid fa-plus"></i> 新增</button>
<button id="nd_preset_rename" class="nd-btn"><i class="fa-solid fa-pen"></i> 更名</button>
<button id="nd_preset_save" class="nd-btn"><i class="fa-solid fa-floppy-disk"></i> 保存</button>
<button id="nd_preset_del" class="nd-btn nd-btn-danger"><i class="fa-solid fa-trash-can"></i> 删除</button>
</div>
</div>
<div class="nd-divider"></div>
<!-- LLM 预设 -->
<div class="nd-section-title">LLM 预设 <span style="color: var(--text-muted); font-weight: 400;">(用于 AI 生成场景标签)</span></div>
<div class="nd-preset-row">
<select id="nd_llm_preset" class="nd-select">
<option value="default">默认预设</option>
</select>
<div class="nd-btn-group">
<button id="nd_llm_add" class="nd-btn" disabled><i class="fa-solid fa-plus"></i> 新增</button>
<button id="nd_llm_rename" class="nd-btn" disabled><i class="fa-solid fa-pen"></i> 更名</button>
<button id="nd_llm_save" class="nd-btn" disabled><i class="fa-solid fa-floppy-disk"></i> 保存</button>
<button id="nd_llm_del" class="nd-btn nd-btn-danger" disabled><i class="fa-solid fa-trash-can"></i> 删除</button>
</div>
</div>
<div class="nd-row cols-2" style="margin-top: 14px;">
<div class="nd-field">
<label>标签数量 <span class="nd-label-hint">(建议 25-60</span></label>
<input id="nd_tag_count" class="nd-input" type="number" value="40" min="10" max="100">
</div>
</div>
</div>
</div>
<!-- 模型与采样 -->
<div class="nd-card">
<div class="nd-card-header">
<div class="nd-card-title"><i class="fa-solid fa-microchip"></i> 模型与采样</div>
<i class="fa-solid fa-chevron-down nd-card-chevron"></i>
</div>
<div class="nd-card-body">
<div class="nd-row cols-3">
<div class="nd-field">
<label>生成模型</label>
<select id="nd_model_sel" class="nd-select">
<option value="nai-diffusion-4-5-full">NAI V4.5 Full</option>
<option value="nai-diffusion-4-5-curated">NAI V4.5 Curated</option>
<option value="nai-diffusion-4-full">NAI V4 Full</option>
<option value="nai-diffusion-4-curated-preview">NAI V4 Curated</option>
<option value="nai-diffusion-3">NAI V3</option>
<option value="nai-diffusion-furry-3">NAI Furry V3</option>
<option value="custom">自定义...</option>
</select>
<div id="nd_model_custom_wrap" class="nd-custom-wrap">
<input id="nd_model" class="nd-input" type="text" placeholder="输入模型 ID">
</div>
</div>
<div class="nd-field">
<label>采样器</label>
<select id="nd_sampler_sel" class="nd-select">
<option value="k_euler_ancestral">Euler Ancestral</option>
<option value="k_euler">Euler</option>
<option value="k_dpmpp_2m">DPM++ 2M</option>
<option value="k_dpmpp_sde">DPM++ SDE</option>
<option value="ddim">DDIM</option>
<option value="custom">自定义...</option>
</select>
<div id="nd_sampler_custom_wrap" class="nd-custom-wrap">
<input id="nd_sampler" class="nd-input" type="text">
</div>
</div>
<div class="nd-field">
<label>调度器</label>
<select id="nd_scheduler_sel" class="nd-select">
<option value="karras">Karras</option>
<option value="native">Native</option>
<option value="exponential">Exponential</option>
<option value="custom">自定义...</option>
</select>
<div id="nd_scheduler_custom_wrap" class="nd-custom-wrap">
<input id="nd_scheduler" class="nd-input" type="text">
</div>
</div>
</div>
</div>
</div>
<!-- 生成参数 -->
<div class="nd-card">
<div class="nd-card-header">
<div class="nd-card-title"><i class="fa-solid fa-sliders"></i> 生成参数</div>
<i class="fa-solid fa-chevron-down nd-card-chevron"></i>
</div>
<div class="nd-card-body">
<div class="nd-section-title">画布尺寸</div>
<div class="nd-row cols-3">
<div class="nd-field">
<label>尺寸预设</label>
<select id="nd_size_preset" class="nd-select">
<option value="832x1216">832×1216 竖图</option>
<option value="1216x832">1216×832 横图</option>
<option value="1024x1024">1024×1024 方图</option>
<option value="768x1280">768×1280 竖图</option>
<option value="1280x768">1280×768 横图</option>
<option value="custom">自定义尺寸...</option>
</select>
</div>
<div class="nd-field nd-custom-wrap" id="nd_custom_w_wrap">
<label>宽度 <span class="nd-label-hint">px</span></label>
<input id="nd_width" class="nd-input" type="number" min="64" max="2048" step="64">
</div>
<div class="nd-field nd-custom-wrap" id="nd_custom_h_wrap">
<label>高度 <span class="nd-label-hint">px</span></label>
<input id="nd_height" class="nd-input" type="number" min="64" max="2048" step="64">
</div>
</div>
<div class="nd-divider"></div>
<div class="nd-section-title">核心参数</div>
<div class="nd-row cols-4">
<div class="nd-field">
<label>步数 <span class="nd-label-hint">Steps</span></label>
<input id="nd_steps" class="nd-input" type="number" min="1" max="80" value="28">
</div>
<div class="nd-field">
<label>引导强度 <span class="nd-label-hint">CFG</span></label>
<input id="nd_scale" class="nd-input" type="number" min="1" max="30" step="0.5" value="9">
</div>
<div class="nd-field">
<label>随机种子 <span class="nd-label-hint">Seed</span></label>
<input id="nd_seed" class="nd-input" type="number" min="-1" value="-1" placeholder="-1 随机">
</div>
<div class="nd-field">
<label>放大倍数 <span class="nd-label-hint">Upscale</span></label>
<input id="nd_upscale" class="nd-input" type="number" min="1" max="4" step="0.5" value="1">
</div>
</div>
<div class="nd-divider"></div>
<div class="nd-section-title">增强选项</div>
<div class="nd-pills">
<label class="nd-pill"><input type="checkbox" id="nd_sm"><span>SMEA 增强</span></label>
<label class="nd-pill"><input type="checkbox" id="nd_sm_dyn"><span>SMEA 动态</span></label>
<label class="nd-pill"><input type="checkbox" id="nd_decrisper"><span>降噪优化</span></label>
<label class="nd-pill"><input type="checkbox" id="nd_variety_boost"><span>多样增强</span></label>
</div>
</div>
</div>
<!-- 固定标签 -->
<div class="nd-card">
<div class="nd-card-header">
<div class="nd-card-title"><i class="fa-solid fa-tags"></i> 固定标签</div>
<span class="nd-card-hint">风格 / 质量控制词</span>
<i class="fa-solid fa-chevron-down nd-card-chevron"></i>
</div>
<div class="nd-card-body">
<div class="nd-row cols-2">
<div class="nd-field">
<label>正向固定词 <span class="nd-label-hint">Positive</span></label>
<textarea id="nd_positive" class="nd-textarea" placeholder="masterpiece, best quality, amazing details, ..."></textarea>
</div>
<div class="nd-field">
<label>负向固定词 <span class="nd-label-hint">Negative</span></label>
<textarea id="nd_negative" class="nd-textarea" placeholder="lowres, bad anatomy, bad hands, missing fingers, ..."></textarea>
</div>
</div>
<div class="nd-help">这些标签会自动添加到每次生成中AI 生成的场景标签不会与此重复。</div>
</div>
</div>
<!-- 场景标签测试 -->
<div class="nd-card">
<div class="nd-card-header">
<div class="nd-card-title"><i class="fa-solid fa-wand-magic-sparkles"></i> 场景标签测试</div>
<i class="fa-solid fa-chevron-down nd-card-chevron"></i>
</div>
<div class="nd-card-body">
<div class="nd-field">
<label>场景标签 <span class="nd-label-hint">逗号分隔的英文标签</span></label>
<textarea id="nd_scene_tags" class="nd-textarea" placeholder="1girl, classroom, sunlight through window, warm atmosphere, depth of field, ..."></textarea>
<div class="nd-help">手动输入或让 AI 根据对话内容自动生成。</div>
</div>
<div class="nd-actions">
<button id="nd_test_preview" class="nd-btn"><i class="fa-solid fa-image"></i> 生成预览</button>
<button id="nd_ai_tags_attach" class="nd-btn"><i class="fa-solid fa-robot"></i> 智能生成</button>
<button id="nd_attach_last" class="nd-btn"><i class="fa-solid fa-paperclip"></i> 追加楼层</button>
<span id="nd_status" class="nd-status"></span>
</div>
<div id="nd_preview" class="nd-preview">
<img id="nd_preview_img" alt="预览图">
</div>
</div>
</div>
</div>
</div>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<script>
let state = {
enabled: false,
mode: 'manual',
selectedPresetId: null,
presets: [],
api: {
mode: 'tavern',
apiKey: '',
},
dirty: false
};
const $ = id => document.getElementById(id);
function postToParent(payload) {
window.parent.postMessage({ source: 'NovelDraw-Frame', ...payload }, '*');
}
window.addEventListener('message', event => {
const data = event.data;
if (!data || data.source !== 'LittleWhiteBox-NovelDraw') return;
switch (data.type) {
case 'INIT_DATA':
state = { ...state, ...data.settings, dirty: false };
applyStateToUI();
break;
case 'PREVIEW_RESULT':
$('nd_preview_img').src = data.url;
$('nd_preview').classList.add('visible');
break;
case 'AI_TAGS_RESULT':
$('nd_scene_tags').value = data.tags;
break;
case 'STATUS':
const statusEl = $('nd_status');
statusEl.textContent = data.text || '';
statusEl.className = 'nd-status ' + (data.state || '');
// 同时更新 API 状态
const apiStatusEl = $('nd_api_status');
apiStatusEl.textContent = data.text || '';
apiStatusEl.className = 'nd-status ' + (data.state || '');
break;
}
});
function applyStateToUI() {
const badge = $('nd_enabled_state');
badge.className = 'nd-badge ' + (state.enabled ? 'enabled' : 'disabled');
badge.querySelector('span').textContent = state.enabled ? '已启用' : '未启用';
$('nd_mode').value = state.mode || 'manual';
const presetSel = $('nd_preset');
presetSel.innerHTML = state.presets.map(p =>
`<option value="${p.id}">${p.name || p.id}</option>`
).join('');
presetSel.value = state.selectedPresetId || '';
// API 设置
document.querySelector(`input[name="api_mode"][value="${state.api?.mode || 'tavern'}"]`).checked = true;
$('nd_api_key').value = state.api?.apiKey || '';
applyPresetToFields();
}
function applyPresetToFields() {
const p = state.presets.find(x => x.id === state.selectedPresetId) || state.presets[0];
if (!p) return;
$('nd_positive').value = p.positivePrefix || '';
$('nd_negative').value = p.negativePrefix || '';
const pm = p.params || {};
setSelectOrCustom('model', pm.model);
setSelectOrCustom('sampler', pm.sampler);
setSelectOrCustom('scheduler', pm.scheduler);
$('nd_steps').value = pm.steps ?? 28;
$('nd_scale').value = pm.scale ?? 9;
$('nd_width').value = pm.width ?? 832;
$('nd_height').value = pm.height ?? 1216;
$('nd_seed').value = pm.seed ?? -1;
$('nd_upscale').value = pm.upscale_ratio ?? 1;
$('nd_sm').checked = !!pm.sm;
$('nd_sm_dyn').checked = !!pm.sm_dyn;
$('nd_decrisper').checked = !!pm.decrisper;
$('nd_variety_boost').checked = !!pm.variety_boost;
updateSizePreset();
state.dirty = false;
}
function setSelectOrCustom(kind, value) {
const sel = $(`nd_${kind}_sel`);
const wrap = $(`nd_${kind}_custom_wrap`);
const input = $(`nd_${kind}`);
const has = [...sel.options].some(o => o.value === value);
sel.value = has ? value : 'custom';
wrap.classList.toggle('visible', sel.value === 'custom');
if (input) input.value = value || '';
}
function updateSizePreset() {
const w = $('nd_width').value;
const h = $('nd_height').value;
const val = `${w}x${h}`;
const sel = $('nd_size_preset');
const has = [...sel.options].some(o => o.value === val);
sel.value = has ? val : 'custom';
const isCustom = sel.value === 'custom';
$('nd_custom_w_wrap').classList.toggle('visible', isCustom);
$('nd_custom_h_wrap').classList.toggle('visible', isCustom);
}
function collectPresetFromFields() {
const p = state.presets.find(x => x.id === state.selectedPresetId);
if (!p) return;
p.positivePrefix = $('nd_positive').value;
p.negativePrefix = $('nd_negative').value;
p.params = p.params || {};
p.params.model = $('nd_model_sel').value === 'custom' ? $('nd_model').value : $('nd_model_sel').value;
p.params.sampler = $('nd_sampler_sel').value === 'custom' ? $('nd_sampler').value : $('nd_sampler_sel').value;
p.params.scheduler = $('nd_scheduler_sel').value === 'custom' ? $('nd_scheduler').value : $('nd_scheduler_sel').value;
p.params.steps = Number($('nd_steps').value) || 28;
p.params.scale = Number($('nd_scale').value) || 9;
p.params.width = Number($('nd_width').value) || 832;
p.params.height = Number($('nd_height').value) || 1216;
p.params.seed = Number($('nd_seed').value);
p.params.upscale_ratio = Number($('nd_upscale').value) || 1;
p.params.sm = $('nd_sm').checked;
p.params.sm_dyn = $('nd_sm_dyn').checked;
p.params.decrisper = $('nd_decrisper').checked;
p.params.variety_boost = $('nd_variety_boost').checked;
state.dirty = true;
}
function savePreset() {
collectPresetFromFields();
postToParent({
type: 'SAVE_PRESET',
selectedPresetId: state.selectedPresetId,
presets: state.presets
});
state.dirty = false;
$('nd_status').textContent = '已保存';
$('nd_status').className = 'nd-status success';
setTimeout(() => {
$('nd_status').textContent = '';
$('nd_status').className = 'nd-status';
}, 1500);
}
function renamePreset() {
const p = state.presets.find(x => x.id === state.selectedPresetId);
if (!p) return;
const newName = prompt('输入新名称:', p.name || '');
if (newName && newName.trim()) {
p.name = newName.trim();
postToParent({
type: 'SAVE_PRESET',
selectedPresetId: state.selectedPresetId,
presets: state.presets
});
applyStateToUI();
}
}
function getApiMode() {
return document.querySelector('input[name="api_mode"]:checked')?.value || 'tavern';
}
function saveApiConfig() {
const apiMode = getApiMode();
const apiKey = $('nd_api_key').value.trim();
state.api = { mode: apiMode, apiKey };
postToParent({ type: 'SAVE_API_CONFIG', apiMode, apiKey });
}
function testApiConnection() {
const apiMode = getApiMode();
const apiKey = $('nd_api_key').value.trim();
postToParent({ type: 'TEST_API_CONNECTION', apiMode, apiKey });
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.nd-card-header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.nd-card').classList.toggle('collapsed');
});
});
$('nd_close').addEventListener('click', () => {
if (state.dirty && !confirm('有未保存的更改,确定关闭?')) return;
postToParent({ type: 'CLOSE' });
});
$('nd_mode').addEventListener('change', () => {
state.mode = $('nd_mode').value;
postToParent({ type: 'SAVE_MODE', mode: state.mode });
});
$('nd_preset').addEventListener('change', () => {
if (state.dirty && !confirm('有未保存的更改,确定切换预设?')) {
$('nd_preset').value = state.selectedPresetId;
return;
}
state.selectedPresetId = $('nd_preset').value;
applyPresetToFields();
postToParent({ type: 'SELECT_PRESET', selectedPresetId: state.selectedPresetId });
});
$('nd_preset_add').addEventListener('click', () => postToParent({ type: 'ADD_PRESET' }));
$('nd_preset_rename').addEventListener('click', renamePreset);
$('nd_preset_save').addEventListener('click', savePreset);
$('nd_preset_del').addEventListener('click', () => {
if (confirm('确定删除当前预设?')) {
postToParent({ type: 'DEL_PRESET' });
}
});
// API 设置
$('nd_save_api').addEventListener('click', saveApiConfig);
$('nd_test_api').addEventListener('click', testApiConnection);
// 密码显示切换
$('nd_toggle_key').addEventListener('click', () => {
const input = $('nd_api_key');
const icon = $('nd_toggle_key').querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'fa-solid fa-eye-slash';
} else {
input.type = 'password';
icon.className = 'fa-solid fa-eye';
}
});
['model', 'sampler', 'scheduler'].forEach(kind => {
$(`nd_${kind}_sel`).addEventListener('change', function() {
$(`nd_${kind}_custom_wrap`).classList.toggle('visible', this.value === 'custom');
collectPresetFromFields();
});
});
$('nd_size_preset').addEventListener('change', function() {
if (this.value === 'custom') {
$('nd_custom_w_wrap').classList.add('visible');
$('nd_custom_h_wrap').classList.add('visible');
} else {
const [w, h] = this.value.split('x');
$('nd_width').value = w;
$('nd_height').value = h;
$('nd_custom_w_wrap').classList.remove('visible');
$('nd_custom_h_wrap').classList.remove('visible');
collectPresetFromFields();
}
});
const fieldIds = [
'nd_positive', 'nd_negative',
'nd_model', 'nd_sampler', 'nd_scheduler',
'nd_steps', 'nd_scale', 'nd_width', 'nd_height', 'nd_seed', 'nd_upscale',
'nd_sm', 'nd_sm_dyn', 'nd_decrisper', 'nd_variety_boost'
];
fieldIds.forEach(id => {
const el = $(id);
if (el) {
el.addEventListener('input', collectPresetFromFields);
el.addEventListener('change', collectPresetFromFields);
}
});
$('nd_test_preview').addEventListener('click', () => {
postToParent({ type: 'TEST_PREVIEW', sceneTags: $('nd_scene_tags').value });
});
$('nd_attach_last').addEventListener('click', () => {
postToParent({ type: 'ATTACH_LAST', sceneTags: $('nd_scene_tags').value });
});
$('nd_ai_tags_attach').addEventListener('click', () => {
postToParent({ type: 'AI_TAGS_ATTACH' });
});
postToParent({ type: 'FRAME_READY' });
});
</script>
</body>
</html>