Files
LittleWhiteBox/modules/novel-draw/novel-draw.html
RT15548 593fce3c8c
2025-12-19 02:19:10 +08:00

1136 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>