1001 lines
36 KiB
JavaScript
1001 lines
36 KiB
JavaScript
// floating-panel.js
|
||
/**
|
||
* NovelDraw 画图按钮面板
|
||
* 和 TTS 播放器一样,每条 AI 消息都有一个
|
||
*/
|
||
|
||
import {
|
||
openNovelDrawSettings,
|
||
generateAndInsertImages,
|
||
getSettings,
|
||
saveSettings,
|
||
classifyError,
|
||
} from './novel-draw.js';
|
||
import { registerToToolbar, removeFromToolbar } from '../../core/message-toolbar.js';
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 常量
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
const AUTO_RESET_DELAY = 8000;
|
||
|
||
const FloatState = {
|
||
IDLE: 'idle',
|
||
LLM: 'llm',
|
||
GEN: 'gen',
|
||
COOLDOWN: 'cooldown',
|
||
SUCCESS: 'success',
|
||
PARTIAL: 'partial',
|
||
ERROR: 'error',
|
||
};
|
||
|
||
const SIZE_OPTIONS = [
|
||
{ value: 'default', label: '跟随预设', width: null, height: null },
|
||
{ value: '832x1216', label: '832 × 1216 竖图', width: 832, height: 1216 },
|
||
{ value: '1216x832', label: '1216 × 832 横图', width: 1216, height: 832 },
|
||
{ value: '1024x1024', label: '1024 × 1024 方图', width: 1024, height: 1024 },
|
||
{ value: '768x1280', label: '768 x 1280 大竖', width: 768, height: 1280 },
|
||
{ value: '1280x768', label: '1280 x 768 大横', width: 1280, height: 768 },
|
||
];
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 状态(每条消息独立)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
const panelMap = new Map(); // messageId -> panelData
|
||
const pendingCallbacks = new Map(); // messageId -> true
|
||
let observer = null;
|
||
let stylesInjected = false;
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 样式 - 菜单向下展开
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
const STYLES = `
|
||
:root {
|
||
--nd-h: 34px;
|
||
--nd-bg: rgba(0, 0, 0, 0.55);
|
||
--nd-bg-hover: rgba(0, 0, 0, 0.7);
|
||
--nd-bg-active: rgba(255, 255, 255, 0.1);
|
||
--nd-border: rgba(255, 255, 255, 0.08);
|
||
--nd-border-hover: rgba(255, 255, 255, 0.2);
|
||
--nd-border-subtle: rgba(255, 255, 255, 0.08);
|
||
--nd-text-primary: rgba(255, 255, 255, 0.85);
|
||
--nd-text-secondary: rgba(255, 255, 255, 0.65);
|
||
--nd-text-muted: rgba(255, 255, 255, 0.45);
|
||
--nd-text-dim: rgba(255, 255, 255, 0.25);
|
||
--nd-success: #3ecf8e;
|
||
--nd-warning: #f0b429;
|
||
--nd-error: #f87171;
|
||
--nd-info: #60a5fa;
|
||
--nd-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||
--nd-radius-sm: 6px;
|
||
--nd-radius-md: 10px;
|
||
--nd-radius-lg: 14px;
|
||
}
|
||
|
||
.nd-float {
|
||
position: relative;
|
||
user-select: none;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
}
|
||
|
||
.nd-capsule {
|
||
width: 74px;
|
||
height: var(--nd-h);
|
||
background: var(--nd-bg);
|
||
border: 1px solid var(--nd-border);
|
||
border-radius: 17px;
|
||
backdrop-filter: blur(16px);
|
||
-webkit-backdrop-filter: blur(16px);
|
||
position: relative;
|
||
overflow: hidden;
|
||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.nd-float:hover .nd-capsule {
|
||
background: var(--nd-bg-hover);
|
||
border-color: var(--nd-border-hover);
|
||
}
|
||
|
||
.nd-float.working .nd-capsule { border-color: rgba(240, 180, 41, 0.5); }
|
||
.nd-float.cooldown .nd-capsule { border-color: rgba(96, 165, 250, 0.6); background: rgba(96, 165, 250, 0.1); }
|
||
.nd-float.success .nd-capsule { border-color: rgba(62, 207, 142, 0.6); background: rgba(62, 207, 142, 0.1); }
|
||
.nd-float.partial .nd-capsule { border-color: rgba(240, 180, 41, 0.6); background: rgba(240, 180, 41, 0.1); }
|
||
.nd-float.error .nd-capsule { border-color: rgba(248, 113, 113, 0.6); background: rgba(248, 113, 113, 0.1); }
|
||
|
||
.nd-inner {
|
||
display: grid;
|
||
width: 100%;
|
||
height: 100%;
|
||
grid-template-areas: "s";
|
||
pointer-events: none;
|
||
}
|
||
|
||
.nd-layer {
|
||
grid-area: s;
|
||
display: flex;
|
||
align-items: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
transition: opacity 0.2s, transform 0.2s;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.nd-layer-idle { opacity: 1; transform: translateY(0); }
|
||
|
||
.nd-float.working .nd-layer-idle,
|
||
.nd-float.cooldown .nd-layer-idle,
|
||
.nd-float.success .nd-layer-idle,
|
||
.nd-float.partial .nd-layer-idle,
|
||
.nd-float.error .nd-layer-idle {
|
||
opacity: 0;
|
||
transform: translateY(-100%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
.nd-btn-draw {
|
||
flex: 1;
|
||
height: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
color: var(--nd-text-primary);
|
||
transition: background 0.15s;
|
||
font-size: 16px;
|
||
}
|
||
.nd-btn-draw:hover { background: rgba(255, 255, 255, 0.12); }
|
||
.nd-btn-draw:active { transform: scale(0.92); }
|
||
|
||
.nd-auto-dot {
|
||
position: absolute;
|
||
top: 7px;
|
||
right: 6px;
|
||
width: 6px;
|
||
height: 6px;
|
||
background: var(--nd-success);
|
||
border-radius: 50%;
|
||
box-shadow: 0 0 6px rgba(62, 207, 142, 0.6);
|
||
opacity: 0;
|
||
transform: scale(0);
|
||
transition: all 0.2s;
|
||
}
|
||
.nd-float.auto-on .nd-auto-dot { opacity: 1; transform: scale(1); }
|
||
|
||
.nd-sep { width: 1px; height: 12px; background: var(--nd-border); }
|
||
|
||
.nd-btn-menu {
|
||
width: 24px;
|
||
height: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--nd-text-dim);
|
||
font-size: 8px;
|
||
opacity: 0.6;
|
||
transition: opacity 0.25s, transform 0.25s;
|
||
}
|
||
.nd-float:hover .nd-btn-menu { opacity: 1; }
|
||
.nd-btn-menu:hover { background: rgba(255, 255, 255, 0.12); color: var(--nd-text-muted); }
|
||
|
||
.nd-arrow { transition: transform 0.2s; }
|
||
.nd-float.expanded .nd-arrow { transform: rotate(180deg); }
|
||
|
||
.nd-layer-active {
|
||
opacity: 0;
|
||
transform: translateY(100%);
|
||
justify-content: center;
|
||
gap: 6px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #fff;
|
||
cursor: pointer;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.nd-float.working .nd-layer-active,
|
||
.nd-float.cooldown .nd-layer-active,
|
||
.nd-float.success .nd-layer-active,
|
||
.nd-float.partial .nd-layer-active,
|
||
.nd-float.error .nd-layer-active {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.nd-float.cooldown .nd-layer-active { color: var(--nd-info); }
|
||
.nd-float.success .nd-layer-active { color: var(--nd-success); }
|
||
.nd-float.partial .nd-layer-active { color: var(--nd-warning); }
|
||
.nd-float.error .nd-layer-active { color: var(--nd-error); }
|
||
|
||
.nd-spin { display: inline-block; animation: nd-spin 1.5s linear infinite; }
|
||
@keyframes nd-spin { to { transform: rotate(360deg); } }
|
||
|
||
.nd-countdown { font-variant-numeric: tabular-nums; min-width: 36px; text-align: center; }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
详情弹窗 - 向下展开
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
.nd-detail {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
right: 0;
|
||
background: rgba(18, 18, 22, 0.96);
|
||
border: 1px solid var(--nd-border);
|
||
border-radius: 12px;
|
||
padding: 12px 16px;
|
||
font-size: 12px;
|
||
color: var(--nd-text-secondary);
|
||
white-space: nowrap;
|
||
box-shadow: var(--nd-shadow-lg);
|
||
backdrop-filter: blur(20px);
|
||
-webkit-backdrop-filter: blur(20px);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
z-index: 100;
|
||
transform: translateY(-6px) scale(0.96);
|
||
transform-origin: top right;
|
||
}
|
||
|
||
.nd-float.show-detail .nd-detail {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
|
||
.nd-detail-row { display: flex; align-items: center; gap: 10px; padding: 3px 0; }
|
||
.nd-detail-row + .nd-detail-row { margin-top: 6px; padding-top: 8px; border-top: 1px solid var(--nd-border-subtle); }
|
||
.nd-detail-icon { opacity: 0.6; font-size: 13px; }
|
||
.nd-detail-label { color: var(--nd-text-muted); }
|
||
.nd-detail-value { margin-left: auto; font-weight: 600; color: var(--nd-text-primary); }
|
||
.nd-detail-value.success { color: var(--nd-success); }
|
||
.nd-detail-value.warning { color: var(--nd-warning); }
|
||
.nd-detail-value.error { color: var(--nd-error); }
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
菜单 - 向下展开
|
||
═══════════════════════════════════════════════════════════════════════════ */
|
||
.nd-menu {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
right: 0;
|
||
width: 190px;
|
||
background: rgba(18, 18, 22, 0.96);
|
||
border: 1px solid var(--nd-border);
|
||
border-radius: 12px;
|
||
padding: 10px;
|
||
box-shadow: var(--nd-shadow-lg);
|
||
backdrop-filter: blur(20px);
|
||
-webkit-backdrop-filter: blur(20px);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transform: translateY(-6px) scale(0.96);
|
||
transform-origin: top right;
|
||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||
z-index: 100;
|
||
}
|
||
|
||
.nd-float.expanded .nd-menu {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
transform: translateY(0) scale(1);
|
||
}
|
||
|
||
.nd-card {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border: 1px solid var(--nd-border-subtle);
|
||
border-radius: var(--nd-radius-md);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.nd-row { display: flex; align-items: center; padding: 2px 0; }
|
||
|
||
.nd-label {
|
||
width: 36px;
|
||
padding-left: 10px;
|
||
font-size: 10px;
|
||
font-weight: 500;
|
||
color: var(--nd-text-muted);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.nd-select {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--nd-text-primary);
|
||
font-size: 12px;
|
||
padding: 10px 8px;
|
||
outline: none;
|
||
cursor: pointer;
|
||
text-align: center;
|
||
text-align-last: center;
|
||
}
|
||
.nd-select:hover { color: #fff; }
|
||
.nd-select option { background: #1a1a1e; color: #eee; text-align: left; }
|
||
.nd-select.size { font-family: "SF Mono", "Menlo", "Consolas", monospace; font-size: 11px; }
|
||
|
||
.nd-inner-sep {
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent 8px, var(--nd-border-subtle) 8px, var(--nd-border-subtle) calc(100% - 8px), transparent calc(100% - 8px));
|
||
}
|
||
|
||
.nd-controls { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
|
||
|
||
.nd-auto {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 9px 12px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px solid var(--nd-border-subtle);
|
||
border-radius: var(--nd-radius-sm);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
.nd-auto:hover { background: rgba(255, 255, 255, 0.08); }
|
||
.nd-auto.on { background: rgba(62, 207, 142, 0.08); border-color: rgba(62, 207, 142, 0.3); }
|
||
|
||
.nd-dot {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
transition: all 0.2s;
|
||
}
|
||
.nd-auto.on .nd-dot { background: var(--nd-success); box-shadow: 0 0 8px rgba(62, 207, 142, 0.5); }
|
||
|
||
.nd-auto-text { font-size: 12px; color: var(--nd-text-muted); }
|
||
.nd-auto:hover .nd-auto-text { color: var(--nd-text-secondary); }
|
||
.nd-auto.on .nd-auto-text { color: rgba(62, 207, 142, 0.95); }
|
||
|
||
.nd-gear {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: 1px solid var(--nd-border-subtle);
|
||
border-radius: var(--nd-radius-sm);
|
||
background: rgba(255, 255, 255, 0.03);
|
||
color: var(--nd-text-muted);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
transition: all 0.15s;
|
||
}
|
||
.nd-gear:hover { background: rgba(255, 255, 255, 0.08); color: var(--nd-text-secondary); }
|
||
`;
|
||
|
||
function injectStyles() {
|
||
if (stylesInjected) return;
|
||
stylesInjected = true;
|
||
|
||
const el = document.createElement('style');
|
||
el.id = 'nd-float-styles';
|
||
el.textContent = STYLES;
|
||
document.head.appendChild(el);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 面板数据结构
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function createPanelData(messageId) {
|
||
return {
|
||
messageId,
|
||
root: null,
|
||
state: FloatState.IDLE,
|
||
result: { success: 0, total: 0, error: null, startTime: 0 },
|
||
autoResetTimer: null,
|
||
cooldownRafId: null,
|
||
cooldownEndTime: 0,
|
||
$cache: {},
|
||
_cleanup: null,
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 面板创建 - 箭头改为向下 ▼
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function buildPresetOptions() {
|
||
const settings = getSettings();
|
||
const presets = settings.paramsPresets || [];
|
||
const currentId = settings.selectedParamsPresetId;
|
||
return presets.map(p =>
|
||
`<option value="${p.id}"${p.id === currentId ? ' selected' : ''}>${p.name || '未命名'}</option>`
|
||
).join('');
|
||
}
|
||
|
||
function buildSizeOptions() {
|
||
const settings = getSettings();
|
||
const current = settings.overrideSize || 'default';
|
||
return SIZE_OPTIONS.map(opt =>
|
||
`<option value="${opt.value}"${opt.value === current ? ' selected' : ''}>${opt.label}</option>`
|
||
).join('');
|
||
}
|
||
|
||
function fillSelectOptions(select, options, currentValue) {
|
||
if (!select) return;
|
||
select.textContent = '';
|
||
options.forEach((opt) => {
|
||
const option = document.createElement('option');
|
||
option.value = opt.value;
|
||
option.textContent = opt.label;
|
||
if (opt.value === currentValue) option.selected = true;
|
||
select.appendChild(option);
|
||
});
|
||
}
|
||
|
||
function createPanelElement(messageId) {
|
||
const settings = getSettings();
|
||
const isAuto = settings.mode === 'auto';
|
||
|
||
const el = document.createElement('div');
|
||
el.className = `nd-float${isAuto ? ' auto-on' : ''}`;
|
||
el.dataset.messageId = messageId;
|
||
|
||
// Template-only UI markup built locally.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
el.innerHTML = `
|
||
<div class="nd-capsule">
|
||
<div class="nd-inner">
|
||
<div class="nd-layer nd-layer-idle">
|
||
<button class="nd-btn-draw" title="点击生成配图">
|
||
<span>🎨</span>
|
||
<span class="nd-auto-dot"></span>
|
||
</button>
|
||
<div class="nd-sep"></div>
|
||
<button class="nd-btn-menu" title="展开菜单">
|
||
<span class="nd-arrow">▼</span>
|
||
</button>
|
||
</div>
|
||
<div class="nd-layer nd-layer-active">
|
||
<span class="nd-status-icon">⏳</span>
|
||
<span class="nd-status-text">分析</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="nd-detail">
|
||
<div class="nd-detail-row">
|
||
<span class="nd-detail-icon">📊</span>
|
||
<span class="nd-detail-label">结果</span>
|
||
<span class="nd-detail-value nd-result">-</span>
|
||
</div>
|
||
<div class="nd-detail-row nd-error-row" style="display:none">
|
||
<span class="nd-detail-icon">💡</span>
|
||
<span class="nd-detail-label">原因</span>
|
||
<span class="nd-detail-value error nd-error">-</span>
|
||
</div>
|
||
<div class="nd-detail-row">
|
||
<span class="nd-detail-icon">⏱</span>
|
||
<span class="nd-detail-label">耗时</span>
|
||
<span class="nd-detail-value nd-time">-</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="nd-menu">
|
||
<div class="nd-card">
|
||
<div class="nd-row">
|
||
<span class="nd-label">预设</span>
|
||
<select class="nd-select nd-preset-select">${buildPresetOptions()}</select>
|
||
</div>
|
||
<div class="nd-inner-sep"></div>
|
||
<div class="nd-row">
|
||
<span class="nd-label">尺寸</span>
|
||
<select class="nd-select size nd-size-select">${buildSizeOptions()}</select>
|
||
</div>
|
||
</div>
|
||
<div class="nd-controls">
|
||
<div class="nd-auto${isAuto ? ' on' : ''} nd-auto-toggle">
|
||
<span class="nd-dot"></span>
|
||
<span class="nd-auto-text">自动配图</span>
|
||
</div>
|
||
<button class="nd-gear nd-settings-btn" title="打开设置">⚙</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return el;
|
||
}
|
||
|
||
function cacheDOM(panelData) {
|
||
const el = panelData.root;
|
||
if (!el) return;
|
||
|
||
panelData.$cache = {
|
||
statusIcon: el.querySelector('.nd-status-icon'),
|
||
statusText: el.querySelector('.nd-status-text'),
|
||
result: el.querySelector('.nd-result'),
|
||
errorRow: el.querySelector('.nd-error-row'),
|
||
error: el.querySelector('.nd-error'),
|
||
time: el.querySelector('.nd-time'),
|
||
presetSelect: el.querySelector('.nd-preset-select'),
|
||
sizeSelect: el.querySelector('.nd-size-select'),
|
||
autoToggle: el.querySelector('.nd-auto-toggle'),
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 状态管理(每个面板独立)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function setState(messageId, state, data = {}) {
|
||
const panelData = panelMap.get(messageId);
|
||
if (!panelData?.root) return;
|
||
|
||
const el = panelData.root;
|
||
panelData.state = state;
|
||
|
||
// 清除旧定时器
|
||
if (panelData.autoResetTimer) {
|
||
clearTimeout(panelData.autoResetTimer);
|
||
panelData.autoResetTimer = null;
|
||
}
|
||
if (state !== FloatState.COOLDOWN && panelData.cooldownRafId) {
|
||
cancelAnimationFrame(panelData.cooldownRafId);
|
||
panelData.cooldownRafId = null;
|
||
panelData.cooldownEndTime = 0;
|
||
}
|
||
|
||
// 移除状态类
|
||
el.classList.remove('working', 'cooldown', 'success', 'partial', 'error', 'show-detail');
|
||
|
||
const { statusIcon, statusText } = panelData.$cache;
|
||
|
||
switch (state) {
|
||
case FloatState.IDLE:
|
||
panelData.result = { success: 0, total: 0, error: null, startTime: 0 };
|
||
break;
|
||
case FloatState.LLM:
|
||
el.classList.add('working');
|
||
panelData.result.startTime = Date.now();
|
||
if (statusIcon) { statusIcon.textContent = '⏳'; statusIcon.className = 'nd-status-icon nd-spin'; }
|
||
if (statusText) statusText.textContent = '分析';
|
||
break;
|
||
case FloatState.GEN:
|
||
el.classList.add('working');
|
||
if (statusIcon) { statusIcon.textContent = '🎨'; statusIcon.className = 'nd-status-icon nd-spin'; }
|
||
if (statusText) statusText.textContent = `${data.current || 0}/${data.total || 0}`;
|
||
panelData.result.total = data.total || 0;
|
||
break;
|
||
case FloatState.COOLDOWN:
|
||
el.classList.add('cooldown');
|
||
if (statusIcon) { statusIcon.textContent = '⏳'; statusIcon.className = 'nd-status-icon nd-spin'; }
|
||
startCooldownTimer(panelData, data.duration);
|
||
break;
|
||
case FloatState.SUCCESS:
|
||
el.classList.add('success');
|
||
if (statusIcon) { statusIcon.textContent = '✓'; statusIcon.className = 'nd-status-icon'; }
|
||
if (statusText) statusText.textContent = `${data.success}/${data.total}`;
|
||
panelData.result.success = data.success;
|
||
panelData.result.total = data.total;
|
||
panelData.autoResetTimer = setTimeout(() => setState(messageId, FloatState.IDLE), AUTO_RESET_DELAY);
|
||
break;
|
||
case FloatState.PARTIAL:
|
||
el.classList.add('partial');
|
||
if (statusIcon) { statusIcon.textContent = '⚠'; statusIcon.className = 'nd-status-icon'; }
|
||
if (statusText) statusText.textContent = `${data.success}/${data.total}`;
|
||
panelData.result.success = data.success;
|
||
panelData.result.total = data.total;
|
||
panelData.autoResetTimer = setTimeout(() => setState(messageId, FloatState.IDLE), AUTO_RESET_DELAY);
|
||
break;
|
||
case FloatState.ERROR:
|
||
el.classList.add('error');
|
||
if (statusIcon) { statusIcon.textContent = '✗'; statusIcon.className = 'nd-status-icon'; }
|
||
if (statusText) statusText.textContent = data.error?.label || '错误';
|
||
panelData.result.error = data.error;
|
||
panelData.autoResetTimer = setTimeout(() => setState(messageId, FloatState.IDLE), AUTO_RESET_DELAY);
|
||
break;
|
||
}
|
||
}
|
||
|
||
function startCooldownTimer(panelData, duration) {
|
||
panelData.cooldownEndTime = Date.now() + duration;
|
||
|
||
function tick() {
|
||
if (!panelData.cooldownEndTime) return;
|
||
const remaining = Math.max(0, panelData.cooldownEndTime - Date.now());
|
||
const statusText = panelData.$cache?.statusText;
|
||
if (statusText) {
|
||
statusText.textContent = `${(remaining / 1000).toFixed(1)}s`;
|
||
statusText.className = 'nd-status-text nd-countdown';
|
||
}
|
||
if (remaining <= 0) {
|
||
panelData.cooldownRafId = null;
|
||
panelData.cooldownEndTime = 0;
|
||
return;
|
||
}
|
||
panelData.cooldownRafId = requestAnimationFrame(tick);
|
||
}
|
||
|
||
panelData.cooldownRafId = requestAnimationFrame(tick);
|
||
}
|
||
|
||
function updateProgress(messageId, current, total) {
|
||
const panelData = panelMap.get(messageId);
|
||
if (!panelData?.root || panelData.state !== FloatState.GEN) return;
|
||
const statusText = panelData.$cache?.statusText;
|
||
if (statusText) statusText.textContent = `${current}/${total}`;
|
||
}
|
||
|
||
function updateDetailPopup(messageId) {
|
||
const panelData = panelMap.get(messageId);
|
||
if (!panelData?.root) return;
|
||
|
||
const { result: resultEl, errorRow, error: errorEl, time: timeEl } = panelData.$cache;
|
||
const { result, state } = panelData;
|
||
|
||
const elapsed = result.startTime
|
||
? ((Date.now() - result.startTime) / 1000).toFixed(1)
|
||
: '-';
|
||
|
||
if (state === FloatState.SUCCESS || state === FloatState.PARTIAL) {
|
||
if (resultEl) {
|
||
resultEl.textContent = `${result.success}/${result.total} 成功`;
|
||
resultEl.className = `nd-detail-value ${state === FloatState.SUCCESS ? 'success' : 'warning'}`;
|
||
}
|
||
if (errorRow) errorRow.style.display = state === FloatState.PARTIAL ? 'flex' : 'none';
|
||
if (errorEl && state === FloatState.PARTIAL) {
|
||
errorEl.textContent = `${result.total - result.success} 张失败`;
|
||
}
|
||
} else if (state === FloatState.ERROR) {
|
||
if (resultEl) {
|
||
resultEl.textContent = '生成失败';
|
||
resultEl.className = 'nd-detail-value error';
|
||
}
|
||
if (errorRow) errorRow.style.display = 'flex';
|
||
if (errorEl) errorEl.textContent = result.error?.desc || '未知错误';
|
||
}
|
||
|
||
if (timeEl) timeEl.textContent = `${elapsed}s`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 事件处理
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function handleDrawClick(messageId) {
|
||
const panelData = panelMap.get(messageId);
|
||
if (!panelData || panelData.state !== FloatState.IDLE) return;
|
||
|
||
try {
|
||
await generateAndInsertImages({
|
||
messageId,
|
||
onStateChange: (state, data) => {
|
||
switch (state) {
|
||
case 'llm': setState(messageId, FloatState.LLM); break;
|
||
case 'gen': setState(messageId, FloatState.GEN, data); break;
|
||
case 'progress': setState(messageId, FloatState.GEN, data); break;
|
||
case 'cooldown': setState(messageId, FloatState.COOLDOWN, data); break;
|
||
case 'success':
|
||
if (data.aborted && data.success === 0) {
|
||
setState(messageId, FloatState.IDLE);
|
||
} else if (data.aborted || data.success < data.total) {
|
||
setState(messageId, FloatState.PARTIAL, data);
|
||
} else {
|
||
setState(messageId, FloatState.SUCCESS, data);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.error('[NovelDraw]', e);
|
||
if (e.message === '已取消') {
|
||
setState(messageId, FloatState.IDLE);
|
||
} else {
|
||
setState(messageId, FloatState.ERROR, { error: classifyError(e) });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleAbort(messageId) {
|
||
try {
|
||
const { abortGeneration } = await import('./novel-draw.js');
|
||
if (abortGeneration()) {
|
||
setState(messageId, FloatState.IDLE);
|
||
toastr?.info?.('已中止');
|
||
}
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 中止失败:', e);
|
||
}
|
||
}
|
||
|
||
function bindPanelEvents(panelData) {
|
||
const { messageId, root: el } = panelData;
|
||
|
||
el.querySelector('.nd-btn-draw')?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
handleDrawClick(messageId);
|
||
});
|
||
|
||
el.querySelector('.nd-btn-menu')?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
el.classList.remove('show-detail');
|
||
if (!el.classList.contains('expanded')) {
|
||
refreshPresetSelect(messageId);
|
||
refreshSizeSelect(messageId);
|
||
}
|
||
el.classList.toggle('expanded');
|
||
});
|
||
|
||
el.querySelector('.nd-layer-active')?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const state = panelData.state;
|
||
if ([FloatState.LLM, FloatState.GEN, FloatState.COOLDOWN].includes(state)) {
|
||
handleAbort(messageId);
|
||
} else if ([FloatState.SUCCESS, FloatState.PARTIAL, FloatState.ERROR].includes(state)) {
|
||
updateDetailPopup(messageId);
|
||
el.classList.toggle('show-detail');
|
||
}
|
||
});
|
||
|
||
panelData.$cache.presetSelect?.addEventListener('change', (e) => {
|
||
const settings = getSettings();
|
||
settings.selectedParamsPresetId = e.target.value;
|
||
saveSettings(settings);
|
||
updateAllPresetSelects();
|
||
});
|
||
|
||
panelData.$cache.sizeSelect?.addEventListener('change', (e) => {
|
||
const settings = getSettings();
|
||
settings.overrideSize = e.target.value;
|
||
saveSettings(settings);
|
||
updateAllSizeSelects();
|
||
});
|
||
|
||
panelData.$cache.autoToggle?.addEventListener('click', () => {
|
||
const settings = getSettings();
|
||
settings.mode = settings.mode === 'auto' ? 'manual' : 'auto';
|
||
saveSettings(settings);
|
||
updateAutoModeUI();
|
||
});
|
||
|
||
el.querySelector('.nd-settings-btn')?.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
el.classList.remove('expanded');
|
||
openNovelDrawSettings();
|
||
});
|
||
|
||
const closeMenu = (e) => {
|
||
if (!el.contains(e.target)) {
|
||
el.classList.remove('expanded', 'show-detail');
|
||
}
|
||
};
|
||
document.addEventListener('click', closeMenu, { passive: true });
|
||
|
||
panelData._cleanup = () => {
|
||
document.removeEventListener('click', closeMenu);
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 全局更新
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function updateAllPresetSelects() {
|
||
const settings = getSettings();
|
||
const presets = settings.paramsPresets || [];
|
||
const currentId = settings.selectedParamsPresetId;
|
||
const options = presets.map(p => ({
|
||
value: p.id,
|
||
label: p.name || 'Unnamed',
|
||
}));
|
||
panelMap.forEach((data) => {
|
||
const select = data.$cache?.presetSelect;
|
||
fillSelectOptions(select, options, currentId);
|
||
});
|
||
}
|
||
|
||
function updateAllSizeSelects() {
|
||
const settings = getSettings();
|
||
const current = settings.overrideSize || 'default';
|
||
const options = SIZE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }));
|
||
panelMap.forEach((data) => {
|
||
const select = data.$cache?.sizeSelect;
|
||
fillSelectOptions(select, options, current);
|
||
});
|
||
}
|
||
|
||
export function updateAutoModeUI() {
|
||
const isAuto = getSettings().mode === 'auto';
|
||
panelMap.forEach((data) => {
|
||
if (!data.root) return;
|
||
data.root.classList.toggle('auto-on', isAuto);
|
||
data.$cache.autoToggle?.classList.toggle('on', isAuto);
|
||
});
|
||
}
|
||
|
||
function refreshPresetSelect(messageId) {
|
||
const data = panelMap.get(messageId);
|
||
const select = data?.$cache?.presetSelect;
|
||
if (select) {
|
||
const settings = getSettings();
|
||
const presets = settings.paramsPresets || [];
|
||
const currentId = settings.selectedParamsPresetId;
|
||
const options = presets.map(p => ({
|
||
value: p.id,
|
||
label: p.name || 'Unnamed',
|
||
}));
|
||
fillSelectOptions(select, options, currentId);
|
||
}
|
||
}
|
||
|
||
function refreshSizeSelect(messageId) {
|
||
const data = panelMap.get(messageId);
|
||
const select = data?.$cache?.sizeSelect;
|
||
if (select) {
|
||
const settings = getSettings();
|
||
const current = settings.overrideSize || 'default';
|
||
const options = SIZE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }));
|
||
fillSelectOptions(select, options, current);
|
||
}
|
||
}
|
||
|
||
export function refreshPresetSelectAll() {
|
||
updateAllPresetSelects();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 面板挂载(懒加载)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function mountPanel(messageEl, messageId) {
|
||
if (panelMap.has(messageId)) {
|
||
const existing = panelMap.get(messageId);
|
||
if (existing.root?.isConnected) return existing;
|
||
existing._cleanup?.();
|
||
panelMap.delete(messageId);
|
||
}
|
||
|
||
injectStyles();
|
||
|
||
const panelData = createPanelData(messageId);
|
||
const panel = createPanelElement(messageId);
|
||
panelData.root = panel;
|
||
|
||
const success = registerToToolbar(messageId, panel, {
|
||
position: 'right',
|
||
id: `novel-draw-${messageId}`
|
||
});
|
||
|
||
if (!success) {
|
||
return null;
|
||
}
|
||
|
||
cacheDOM(panelData);
|
||
bindPanelEvents(panelData);
|
||
|
||
panelMap.set(messageId, panelData);
|
||
return panelData;
|
||
}
|
||
|
||
function setupObserver() {
|
||
if (observer) return;
|
||
|
||
observer = new IntersectionObserver((entries) => {
|
||
const toMount = [];
|
||
|
||
for (const entry of entries) {
|
||
if (!entry.isIntersecting) continue;
|
||
|
||
const el = entry.target;
|
||
const mid = Number(el.getAttribute('mesid'));
|
||
|
||
if (pendingCallbacks.has(mid)) {
|
||
toMount.push({ el, mid });
|
||
pendingCallbacks.delete(mid);
|
||
observer.unobserve(el);
|
||
}
|
||
}
|
||
|
||
if (toMount.length > 0) {
|
||
requestAnimationFrame(() => {
|
||
for (const { el, mid } of toMount) {
|
||
mountPanel(el, mid);
|
||
}
|
||
});
|
||
}
|
||
}, { rootMargin: '300px' });
|
||
}
|
||
|
||
/**
|
||
* 确保面板存在
|
||
* @param {HTMLElement} messageEl - 消息元素
|
||
* @param {number} messageId - 消息 ID
|
||
* @param {Object} options
|
||
* @param {boolean} options.force - 强制立即挂载,跳过懒加载
|
||
*/
|
||
export function ensureNovelDrawPanel(messageEl, messageId, options = {}) {
|
||
const { force = false } = options;
|
||
|
||
injectStyles();
|
||
|
||
if (panelMap.has(messageId)) {
|
||
const existing = panelMap.get(messageId);
|
||
if (existing.root?.isConnected) return existing;
|
||
existing._cleanup?.();
|
||
panelMap.delete(messageId);
|
||
}
|
||
|
||
if (force) {
|
||
return mountPanel(messageEl, messageId);
|
||
}
|
||
|
||
const rect = messageEl.getBoundingClientRect();
|
||
if (rect.top < window.innerHeight + 500 && rect.bottom > -500) {
|
||
return mountPanel(messageEl, messageId);
|
||
}
|
||
|
||
setupObserver();
|
||
pendingCallbacks.set(messageId, true);
|
||
observer.observe(messageEl);
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 为指定消息设置面板状态
|
||
* @param {number} messageId - 消息 ID
|
||
* @param {string} state - 状态
|
||
* @param {Object} data - 附加数据
|
||
*/
|
||
export function setStateForMessage(messageId, state, data = {}) {
|
||
let panelData = panelMap.get(messageId);
|
||
|
||
if (!panelData?.root?.isConnected) {
|
||
const messageEl = document.querySelector(`.mes[mesid="${messageId}"]`);
|
||
if (messageEl) {
|
||
panelData = ensureNovelDrawPanel(messageEl, messageId, { force: true });
|
||
}
|
||
}
|
||
|
||
if (!panelData) {
|
||
console.warn(`[NovelDraw] 无法为消息 ${messageId} 设置状态`);
|
||
return;
|
||
}
|
||
|
||
setState(messageId, state, data);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 清理
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
export function destroyFloatingPanel() {
|
||
panelMap.forEach((data, messageId) => {
|
||
if (data.autoResetTimer) clearTimeout(data.autoResetTimer);
|
||
if (data.cooldownRafId) cancelAnimationFrame(data.cooldownRafId);
|
||
data._cleanup?.();
|
||
if (data.root) removeFromToolbar(messageId, data.root);
|
||
});
|
||
panelMap.clear();
|
||
pendingCallbacks.clear();
|
||
|
||
observer?.disconnect();
|
||
observer = null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 导出
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
export {
|
||
FloatState,
|
||
updateProgress,
|
||
refreshPresetSelectAll as refreshPresetSelect,
|
||
SIZE_OPTIONS,
|
||
};
|