Add files via upload
This commit is contained in:
317
modules/novel-draw/TAG编写指南.md
Normal file
317
modules/novel-draw/TAG编写指南.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# NOVEL 图像生成 Tag 编写指南(LLM 专用)
|
||||
|
||||
## 一、基础语法规则
|
||||
|
||||
### 1.1 格式规范
|
||||
- Tag 之间使用 **英文逗号 + 空格** 分隔
|
||||
- 示例:`1girl, flower field, sunset`
|
||||
- 所有 Tag 使用英文
|
||||
|
||||
### 1.2 Tag 顺序原则
|
||||
**越靠前的 Tag 影响力越大**,编写时应按以下优先级排列:
|
||||
1. 核心主体(角色数量/性别)
|
||||
2. 整体风格/艺术家
|
||||
3. 品质 Tag
|
||||
4. 外观特征(发型、眼睛、皮肤等)
|
||||
5. 服装细节
|
||||
6. 构图/视角
|
||||
7. 场景/背景
|
||||
8. 氛围/光照/色彩
|
||||
|
||||
---
|
||||
|
||||
## 二、核心 Tag 类别速查
|
||||
|
||||
### 2.1 主体定义
|
||||
|
||||
| 场景 | 推荐 Tag |
|
||||
|------|----------|
|
||||
| 单个女性 | `1girl, solo` |
|
||||
| 单个男性 | `1boy, solo` |
|
||||
| 多个女性 | `2girls` / `3girls` / `multiple girls` |
|
||||
| 多个男性 | `2boys` / `multiple boys` |
|
||||
| 无人物 | `no humans` |
|
||||
| 混合 | `1boy, 1girl` |
|
||||
|
||||
> `solo` 可防止背景出现额外人物
|
||||
|
||||
### 2.2 头发描述
|
||||
|
||||
**长度:**
|
||||
- `very short hair` / `short hair` / `medium hair` / `long hair` / `very long hair` / `absurdly long hair`
|
||||
|
||||
**发型:**
|
||||
- `bob cut`(波波头)
|
||||
- `ponytail` / `high ponytail` / `low ponytail`(马尾)
|
||||
- `twintails`(双马尾)
|
||||
- `bangs` / `blunt bangs` / `side bangs`(刘海)
|
||||
- `braid` / `twin braids`(辫子)
|
||||
- `curly hair`(卷发)
|
||||
- `messy hair`(凌乱)
|
||||
- `ahoge`(呆毛)
|
||||
|
||||
**颜色:**
|
||||
- 基础:`black hair`, `blonde hair`, `brown hair`, `red hair`, `blue hair`, `pink hair`, `white hair`, `silver hair`, `purple hair`, `green hair`
|
||||
- 特殊:`multicolored hair`, `gradient hair`, `streaked hair`
|
||||
|
||||
### 2.3 眼睛描述
|
||||
|
||||
**颜色:**
|
||||
`blue eyes`, `red eyes`, `green eyes`, `brown eyes`, `purple eyes`, `yellow eyes`, `golden eyes`, `heterochromia`(异色瞳)
|
||||
|
||||
**特征:**
|
||||
- `slit pupils`(竖瞳/猫眼)
|
||||
- `glowing eyes`(发光)
|
||||
- `closed eyes`(闭眼)
|
||||
- `half-closed eyes`(半闭眼)
|
||||
|
||||
### 2.4 皮肤描述
|
||||
|
||||
**肤色:**
|
||||
- `pale skin`(白皙)
|
||||
- `fair skin`(浅肤色)
|
||||
- `tan` / `tanned`(小麦色)
|
||||
- `dark skin`(深色)
|
||||
- `colored skin`(幻想色,需配合具体颜色如 `blue skin`)
|
||||
|
||||
**细节:**
|
||||
`freckles`(雀斑), `mole`(痣), `mole under eye`(眼下痣), `makeup`(化妆)
|
||||
|
||||
### 2.5 身体特征
|
||||
|
||||
**体型:**
|
||||
`skinny`, `slim`, `curvy`, `muscular`, `muscular female`, `petite`, `tall`, `short`
|
||||
|
||||
**胸部(女性):**
|
||||
`flat chest`, `small breasts`, `medium breasts`, `large breasts`, `huge breasts`
|
||||
|
||||
### 2.6 服装
|
||||
|
||||
**原则:需要具体描述每个组成部分**
|
||||
|
||||
**头部:**
|
||||
`hat`, `witch hat`, `beret`, `crown`, `hair ribbon`, `hairband`, `glasses`
|
||||
|
||||
**上身:**
|
||||
`shirt`, `dress shirt`, `blouse`, `sweater`, `hoodie`, `jacket`, `coat`, `vest`, `dress`, `kimono`
|
||||
|
||||
**下身:**
|
||||
`skirt`, `long skirt`, `miniskirt`, `pants`, `shorts`, `jeans`
|
||||
|
||||
**足部:**
|
||||
`boots`, `high heels`, `sneakers`, `barefoot`, `thighhighs`, `pantyhose`, `socks`
|
||||
|
||||
**配饰:**
|
||||
`scarf`, `necklace`, `earrings`, `gloves`, `bag`
|
||||
|
||||
**颜色/材质前缀:**
|
||||
可在服装前加颜色或材质,如 `white dress`, `leather jacket`, `silk ribbon`
|
||||
|
||||
### 2.7 艺术风格与媒介
|
||||
|
||||
**数字媒介:**
|
||||
- `anime screencap`(动画截图风格)
|
||||
- `game cg`(游戏CG)
|
||||
- `pixel art`(像素艺术)
|
||||
- `3d`(3D渲染)
|
||||
- `official art`(官方设定风格)
|
||||
|
||||
**传统艺术:**
|
||||
- `realistic` / `photorealistic`(写实/照片级写实)
|
||||
- `impressionism`(印象派)
|
||||
- `art nouveau`(新艺术运动)
|
||||
- `ukiyo-e`(浮世绘)
|
||||
- `sketch`(素描)
|
||||
- `lineart`(线稿)
|
||||
- `watercolor`(水彩)
|
||||
|
||||
**年代风格:**
|
||||
- `retro artstyle`(复古)
|
||||
- `year 2014`(特定年份风格)
|
||||
|
||||
### 2.8 品质 Tag
|
||||
|
||||
**常用组合:**
|
||||
```
|
||||
masterpiece, best quality, very aesthetic, absurdres, ultra detailed
|
||||
```
|
||||
|
||||
| Tag | 作用 |
|
||||
|-----|------|
|
||||
| `masterpiece` | 杰作级质量 |
|
||||
| `best quality` | 最佳质量 |
|
||||
| `high quality` | 高质量 |
|
||||
| `very aesthetic` | 高美感 |
|
||||
| `absurdres` | 超高分辨率 |
|
||||
| `ultra detailed` | 极致细节 |
|
||||
|
||||
### 2.9 构图与取景
|
||||
|
||||
**取景范围:**
|
||||
- `close-up`(特写)
|
||||
- `portrait`(肖像/头肩)
|
||||
- `upper body`(上半身)
|
||||
- `cowboy shot`(到大腿)
|
||||
- `full body`(全身)
|
||||
- `wide shot`(远景)
|
||||
|
||||
**视角:**
|
||||
- `from front`(正面)
|
||||
- `from side`(侧面)
|
||||
- `from behind`(背面)
|
||||
- `from above`(俯视)
|
||||
- `from below`(仰视)
|
||||
- `dutch angle`(倾斜视角)
|
||||
- `profile`(正侧面轮廓)
|
||||
|
||||
**特殊:**
|
||||
- `multiple views`(多视图)
|
||||
- `reference sheet`(角色设定图)
|
||||
|
||||
### 2.10 氛围、光照与色彩
|
||||
|
||||
**光照:**
|
||||
- `cinematic lighting`(电影感光照)
|
||||
- `volumetric lighting`(体积光)
|
||||
- `backlighting`(逆光)
|
||||
- `soft lighting`(柔光)
|
||||
- `dramatic lighting`(戏剧性光照)
|
||||
- `golden hour`(黄金时段光线)
|
||||
- `bloom`(光晕)
|
||||
- `bokeh`(焦外虚化)
|
||||
- `lens flare`(镜头光晕)
|
||||
|
||||
**色彩风格:**
|
||||
- `monochrome`(单色)
|
||||
- `greyscale`(灰度)
|
||||
- `sepia`(棕褐色调)
|
||||
- `limited palette`(有限调色板)
|
||||
- `high contrast`(高对比度)
|
||||
- `flat color`(平涂)
|
||||
- `vibrant colors`(鲜艳色彩)
|
||||
|
||||
**主题色:**
|
||||
`blue theme`, `red theme`, `dark theme`, `warm colors`, `cool colors`
|
||||
|
||||
**氛围:**
|
||||
`mysterious`, `serene`, `melancholic`, `joyful`, `dark`, `ethereal`
|
||||
|
||||
---
|
||||
|
||||
## 三、权重控制语法
|
||||
|
||||
### 3.1 增强权重
|
||||
|
||||
**花括号方式:**
|
||||
```
|
||||
{tag} → 约 1.05 倍
|
||||
{{tag}} → 约 1.10 倍
|
||||
{{{tag}}} → 约 1.16 倍
|
||||
```
|
||||
|
||||
**数值化方式(推荐):**
|
||||
```
|
||||
1.2::tag:: → 1.2 倍权重
|
||||
1.5::tag1, tag2:: → 对多个 tag 同时增强
|
||||
```
|
||||
|
||||
### 3.2 削弱权重
|
||||
|
||||
**方括号方式:**
|
||||
```
|
||||
[tag] → 削弱
|
||||
[[tag]] → 更强削弱
|
||||
```
|
||||
|
||||
**数值化方式(推荐):**
|
||||
```
|
||||
0.8::tag:: → 0.8 倍权重
|
||||
0.5::tag:: → 0.5 倍权重
|
||||
```
|
||||
|
||||
### 3.3 语法技巧
|
||||
- `::` 可结束强调区域
|
||||
- `::` 可自动闭合未配对的括号,如 `{{{{{tag ::`
|
||||
|
||||
---
|
||||
|
||||
## 四、从文本生成 Tag 的工作流程
|
||||
|
||||
### 步骤 1:识别核心要素
|
||||
从描述中提取:
|
||||
- 人物数量和性别
|
||||
- 整体风格/氛围
|
||||
|
||||
### 步骤 2:提取外观特征
|
||||
按顺序识别:
|
||||
- 发型、发色
|
||||
- 眼睛颜色/特征
|
||||
- 肤色
|
||||
- 体型
|
||||
|
||||
### 步骤 3:识别服装
|
||||
分层描述:
|
||||
- 头饰
|
||||
- 上装
|
||||
- 下装
|
||||
- 鞋袜
|
||||
- 配饰
|
||||
|
||||
### 步骤 4:确定构图
|
||||
- 取景范围
|
||||
- 视角
|
||||
- 特殊构图需求
|
||||
|
||||
### 步骤 5:设定氛围
|
||||
- 光照条件
|
||||
- 色彩倾向
|
||||
- 情感基调
|
||||
|
||||
### 步骤 6:添加品质和风格 Tag
|
||||
- 品质 Tag
|
||||
- 艺术风格(如需要)
|
||||
|
||||
### 步骤 7:组装并调整权重
|
||||
- 按优先级排列
|
||||
- 对重要元素增强权重
|
||||
- 编写负面提示词
|
||||
|
||||
---
|
||||
|
||||
## 五、输出格式模板
|
||||
|
||||
```
|
||||
主体, 品质Tag, 艺术风格, 发型, 发色, 眼睛, 皮肤, 体型, 服装细节, 构图, 场景, 光照, 色彩氛围
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实例演示
|
||||
|
||||
### 输入描述:
|
||||
> "一个有着长长银色头发和红色眼睛的神秘女巫,穿着黑色斗篷和尖顶帽,站在月光下的森林中,整体氛围阴郁而神秘"
|
||||
|
||||
### 输出 Tag:
|
||||
|
||||
```
|
||||
1girl, solo, masterpiece, best quality, very aesthetic, witch, long hair, silver hair, red eyes, pale skin, witch hat, black cloak, black robe, full body, standing, forest, night, moonlight, dark atmosphere, mysterious, cinematic lighting, volumetric lighting, {{{dark theme}}}, high contrast
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. **具体优于抽象**:`blue pleated skirt` 优于 `skirt`
|
||||
2. **避免冲突 Tag**:如同时使用 `short hair` 和 `long hair`
|
||||
3. **肤色需明确指定**:不指定时默认倾向白皙
|
||||
4. **服装描述要分层**:每个部件单独描述效果更好
|
||||
5. **NSFW 场景必须明确**:如果场景涉及性内容,必须使用精确的标签。包括:
|
||||
- 身体部位:`penis`, `anus`, `vagina`, `pubic_hair` 等
|
||||
- 性行为方式:`oral`, `fellatio`, `gokkun`, `anal sex`, `vaginal sex` 等
|
||||
- 体位:`missionary`, `doggystyle`, `mating_press`, `deepthroat` 等
|
||||
- 插入场景中:须添加 `cross section`, `X-ray` 展示体内透视效果
|
||||
- 进程阶段:`gently thrusting`, `hard thrusting`, `squirting`, `orgasm` 等
|
||||
- 射精相关:`cum inside`, `cum in uterus`, `stomach bulge`, `female_ejaculation` 等
|
||||
|
||||
**切勿模糊描述**,NovelAI 需要具体的解剖学标签才能正确渲染。
|
||||
758
modules/novel-draw/floating-panel.js
Normal file
758
modules/novel-draw/floating-panel.js
Normal file
@@ -0,0 +1,758 @@
|
||||
// floating-panel.js
|
||||
// Novel Draw 悬浮面板 - 冷却倒计时优化版(修复版)
|
||||
|
||||
import {
|
||||
openNovelDrawSettings,
|
||||
generateAndInsertImages,
|
||||
getSettings,
|
||||
saveSettings,
|
||||
isModuleEnabled,
|
||||
findLastAIMessageId,
|
||||
classifyError,
|
||||
ErrorType,
|
||||
} from './novel-draw.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 常量
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const FLOAT_POS_KEY = 'xb_novel_float_pos';
|
||||
const AUTO_RESET_DELAY = 8000;
|
||||
|
||||
const FloatState = {
|
||||
IDLE: 'idle',
|
||||
LLM: 'llm',
|
||||
GEN: 'gen',
|
||||
COOLDOWN: 'cooldown',
|
||||
SUCCESS: 'success',
|
||||
PARTIAL: 'partial',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 状态
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let floatEl = null;
|
||||
let dragState = null;
|
||||
let currentState = FloatState.IDLE;
|
||||
let currentResult = { success: 0, total: 0, error: null, startTime: 0 };
|
||||
let autoResetTimer = null;
|
||||
|
||||
// 冷却倒计时相关
|
||||
let cooldownTimer = null;
|
||||
let cooldownEndTime = 0;
|
||||
|
||||
// DOM 缓存
|
||||
let $cache = {};
|
||||
|
||||
function cacheDOM() {
|
||||
if (!floatEl) return;
|
||||
$cache = {
|
||||
capsule: floatEl.querySelector('.nd-capsule'),
|
||||
statusIcon: floatEl.querySelector('#nd-status-icon'),
|
||||
statusText: floatEl.querySelector('#nd-status-text'),
|
||||
detailResult: floatEl.querySelector('#nd-detail-result'),
|
||||
detailErrorRow: floatEl.querySelector('#nd-detail-error-row'),
|
||||
detailError: floatEl.querySelector('#nd-detail-error'),
|
||||
detailTime: floatEl.querySelector('#nd-detail-time'),
|
||||
presetSelect: floatEl.querySelector('#nd-preset-select'),
|
||||
autoDot: floatEl.querySelector('#nd-menu-auto-dot'),
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 样式
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const STYLES = `
|
||||
:root {
|
||||
--nd-w: 74px; --nd-h: 34px;
|
||||
--nd-bg: rgba(28,28,32,0.96);
|
||||
--nd-border: rgba(255,255,255,0.12);
|
||||
--nd-accent: #d4a574;
|
||||
--nd-success: #3ecf8e;
|
||||
--nd-warning: #f0b429;
|
||||
--nd-error: #f87171;
|
||||
--nd-cooldown: #60a5fa;
|
||||
}
|
||||
.nd-float { position: fixed; z-index: 10000; user-select: none; }
|
||||
.nd-capsule {
|
||||
width: var(--nd-w); height: var(--nd-h);
|
||||
background: var(--nd-bg);
|
||||
border: 1px solid var(--nd-border);
|
||||
border-radius: 17px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
|
||||
position: relative; overflow: hidden;
|
||||
transition: all 0.25s ease;
|
||||
touch-action: none; cursor: grab;
|
||||
}
|
||||
.nd-capsule:active { cursor: grabbing; }
|
||||
.nd-float:hover .nd-capsule {
|
||||
border-color: rgba(255,255,255,0.25);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 状态边框 */
|
||||
.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.08); }
|
||||
.nd-float.success .nd-capsule { border-color: rgba(62,207,142,0.6); background: rgba(62,207,142,0.08); }
|
||||
.nd-float.partial .nd-capsule { border-color: rgba(240,180,41,0.6); background: rgba(240,180,41,0.08); }
|
||||
.nd-float.error .nd-capsule { border-color: rgba(248,113,113,0.6); background: rgba(248,113,113,0.08); }
|
||||
|
||||
/* 层叠 */
|
||||
.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: rgba(255,255,255,0.9); transition: background 0.15s;
|
||||
font-size: 16px;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
.nd-btn-draw:hover { background: rgba(255,255,255,0.08); }
|
||||
.nd-btn-draw:active { background: rgba(255,255,255,0.12); }
|
||||
|
||||
.nd-auto-dot {
|
||||
position: absolute; top: 7px; right: 6px; width: 6px; height: 6px;
|
||||
background: var(--nd-success); border-radius: 50%;
|
||||
box-shadow: 0 0 4px 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: 14px; background: rgba(255,255,255,0.1); }
|
||||
|
||||
.nd-btn-menu {
|
||||
width: 28px; height: 100%; border: none; background: transparent;
|
||||
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||
color: rgba(255,255,255,0.4); font-size: 8px; transition: all 0.15s;
|
||||
}
|
||||
.nd-btn-menu:hover { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.8); }
|
||||
|
||||
.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;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
.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-cooldown); }
|
||||
.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); }
|
||||
|
||||
/* 🔧 修复1:旋转动画 */
|
||||
.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; bottom: calc(100% + 8px); left: 50%;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
background: rgba(20,20,24,0.98);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 8px; padding: 10px 14px;
|
||||
font-size: 11px; color: rgba(255,255,255,0.8);
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||
opacity: 0; visibility: hidden;
|
||||
transition: all 0.15s ease; z-index: 10;
|
||||
}
|
||||
.nd-detail::after {
|
||||
content: ''; position: absolute; bottom: -5px; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: rgba(20,20,24,0.98);
|
||||
}
|
||||
.nd-float.show-detail .nd-detail {
|
||||
opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
.nd-detail-row { display: flex; align-items: center; gap: 8px; padding: 2px 0; }
|
||||
.nd-detail-row + .nd-detail-row {
|
||||
margin-top: 4px; padding-top: 6px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.nd-detail-icon { opacity: 0.6; }
|
||||
.nd-detail-label { color: rgba(255,255,255,0.5); }
|
||||
.nd-detail-value { margin-left: auto; font-weight: 600; }
|
||||
.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; bottom: calc(100% + 8px); right: 0;
|
||||
width: 180px; background: rgba(28,28,32,0.98);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 10px; padding: 6px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
opacity: 0; visibility: hidden;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
transform-origin: bottom right;
|
||||
transition: all 0.15s cubic-bezier(0.34,1.56,0.64,1);
|
||||
z-index: 5;
|
||||
}
|
||||
.nd-float.expanded .nd-menu {
|
||||
opacity: 1; visibility: visible; transform: translateY(0) scale(1);
|
||||
}
|
||||
.nd-menu-header {
|
||||
padding: 6px 10px 4px;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.35);
|
||||
}
|
||||
.nd-menu-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 6px;
|
||||
cursor: pointer; color: rgba(255,255,255,0.75);
|
||||
font-size: 12px; transition: background 0.1s;
|
||||
}
|
||||
.nd-menu-item:hover { background: rgba(255,255,255,0.08); }
|
||||
.nd-menu-item.active { color: var(--accent); }
|
||||
.nd-item-icon { width: 14px; text-align: center; font-size: 10px; opacity: 0.5; }
|
||||
.nd-menu-item.active .nd-item-icon { opacity: 1; }
|
||||
.nd-menu-divider { height: 1px; background: rgba(255,255,255,0.08); margin: 4px 0; }
|
||||
.nd-menu-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.2); margin-left: auto; transition: all 0.2s;
|
||||
}
|
||||
.nd-menu-dot.active {
|
||||
background: var(--nd-success);
|
||||
box-shadow: 0 0 6px rgba(62,207,142,0.6);
|
||||
}
|
||||
|
||||
/* 预设下拉框 */
|
||||
.nd-preset-row { padding: 4px 10px 8px; }
|
||||
.nd-preset-select {
|
||||
width: 100%; padding: 6px 8px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 6px;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 12px; cursor: pointer; outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.nd-preset-select:hover { border-color: rgba(255,255,255,0.25); }
|
||||
.nd-preset-select:focus { border-color: var(--nd-accent); }
|
||||
.nd-preset-select option { background: #1a1a1e; color: #fff; }
|
||||
`;
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('nd-float-styles')) return;
|
||||
const el = document.createElement('style');
|
||||
el.id = 'nd-float-styles';
|
||||
el.textContent = STYLES;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 位置管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getPosition() {
|
||||
try {
|
||||
const raw = localStorage.getItem(FLOAT_POS_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
|
||||
const debug = document.getElementById('xiaobaix-debug-mini');
|
||||
if (debug) {
|
||||
const r = debug.getBoundingClientRect();
|
||||
return { left: r.left, top: r.bottom + 8 };
|
||||
}
|
||||
return { left: window.innerWidth - 110, top: window.innerHeight - 80 };
|
||||
}
|
||||
|
||||
function savePosition() {
|
||||
if (!floatEl) return;
|
||||
const r = floatEl.getBoundingClientRect();
|
||||
try {
|
||||
localStorage.setItem(FLOAT_POS_KEY, JSON.stringify({
|
||||
left: Math.round(r.left),
|
||||
top: Math.round(r.top)
|
||||
}));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function applyPosition() {
|
||||
if (!floatEl) return;
|
||||
const pos = getPosition();
|
||||
const w = floatEl.offsetWidth || 77;
|
||||
const h = floatEl.offsetHeight || 34;
|
||||
floatEl.style.left = `${Math.max(0, Math.min(pos.left, window.innerWidth - w))}px`;
|
||||
floatEl.style.top = `${Math.max(0, Math.min(pos.top, window.innerHeight - h))}px`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 冷却倒计时
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function clearCooldownTimer() {
|
||||
if (cooldownTimer) {
|
||||
clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
}
|
||||
cooldownEndTime = 0;
|
||||
}
|
||||
|
||||
function startCooldownTimer(duration) {
|
||||
clearCooldownTimer();
|
||||
|
||||
cooldownEndTime = Date.now() + duration;
|
||||
|
||||
// 立即更新一次
|
||||
updateCooldownDisplay();
|
||||
|
||||
// 🔧 修复3:每50ms更新一次,更流畅,且始终更新显示
|
||||
cooldownTimer = setInterval(() => {
|
||||
updateCooldownDisplay();
|
||||
|
||||
// 倒计时结束后清理定时器(但不切换状态,等 novel-draw.js 来切换)
|
||||
if (cooldownEndTime - Date.now() <= -100) {
|
||||
clearCooldownTimer();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function updateCooldownDisplay() {
|
||||
const { statusIcon, statusText } = $cache;
|
||||
if (!statusIcon || !statusText) return;
|
||||
|
||||
// 🔧 修复2 & 3:显示小数点后一位,最小显示0.0
|
||||
const remaining = Math.max(0, cooldownEndTime - Date.now());
|
||||
const seconds = (remaining / 1000).toFixed(1);
|
||||
|
||||
statusText.textContent = `${seconds}s`;
|
||||
statusText.className = 'nd-countdown';
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 状态管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// 🔧 修复1:spinning 设为 true
|
||||
const STATE_CONFIG = {
|
||||
[FloatState.IDLE]: { cls: '', icon: '', text: '', spinning: false },
|
||||
[FloatState.LLM]: { cls: 'working', icon: '⏳', text: '分析', spinning: true },
|
||||
[FloatState.GEN]: { cls: 'working', icon: '🎨', text: '', spinning: true },
|
||||
[FloatState.COOLDOWN]: { cls: 'cooldown', icon: '⏳', text: '', spinning: true },
|
||||
[FloatState.SUCCESS]: { cls: 'success', icon: '✓', text: '', spinning: false },
|
||||
[FloatState.PARTIAL]: { cls: 'partial', icon: '⚠', text: '', spinning: false },
|
||||
[FloatState.ERROR]: { cls: 'error', icon: '✗', text: '', spinning: false },
|
||||
};
|
||||
|
||||
function setState(state, data = {}) {
|
||||
if (!floatEl) return;
|
||||
|
||||
currentState = state;
|
||||
|
||||
// 清理自动重置定时器
|
||||
if (autoResetTimer) {
|
||||
clearTimeout(autoResetTimer);
|
||||
autoResetTimer = null;
|
||||
}
|
||||
|
||||
// 非冷却状态时清理冷却定时器
|
||||
if (state !== FloatState.COOLDOWN) {
|
||||
clearCooldownTimer();
|
||||
}
|
||||
|
||||
// 移除所有状态类
|
||||
floatEl.classList.remove('working', 'cooldown', 'success', 'partial', 'error', 'show-detail');
|
||||
|
||||
const cfg = STATE_CONFIG[state];
|
||||
if (cfg.cls) floatEl.classList.add(cfg.cls);
|
||||
|
||||
const { statusIcon, statusText } = $cache;
|
||||
if (!statusIcon || !statusText) return;
|
||||
|
||||
// 🔧 修复1:根据 spinning 添加旋转类
|
||||
statusIcon.textContent = cfg.icon;
|
||||
statusIcon.className = cfg.spinning ? 'nd-spin' : '';
|
||||
statusText.className = '';
|
||||
|
||||
switch (state) {
|
||||
case FloatState.IDLE:
|
||||
currentResult = { success: 0, total: 0, error: null, startTime: 0 };
|
||||
break;
|
||||
|
||||
case FloatState.LLM:
|
||||
currentResult.startTime = Date.now();
|
||||
statusText.textContent = cfg.text;
|
||||
break;
|
||||
|
||||
case FloatState.GEN:
|
||||
statusText.textContent = `${data.current || 0}/${data.total || 0}`;
|
||||
currentResult.total = data.total || 0;
|
||||
break;
|
||||
|
||||
case FloatState.COOLDOWN:
|
||||
// 启动冷却倒计时
|
||||
startCooldownTimer(data.duration);
|
||||
break;
|
||||
|
||||
case FloatState.SUCCESS:
|
||||
case FloatState.PARTIAL:
|
||||
statusText.textContent = `${data.success}/${data.total}`;
|
||||
currentResult.success = data.success;
|
||||
currentResult.total = data.total;
|
||||
autoResetTimer = setTimeout(() => setState(FloatState.IDLE), AUTO_RESET_DELAY);
|
||||
break;
|
||||
|
||||
case FloatState.ERROR:
|
||||
statusText.textContent = data.error?.label || '错误';
|
||||
currentResult.error = data.error;
|
||||
autoResetTimer = setTimeout(() => setState(FloatState.IDLE), AUTO_RESET_DELAY);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress(current, total) {
|
||||
if (currentState !== FloatState.GEN || !$cache.statusText) return;
|
||||
$cache.statusText.textContent = `${current}/${total}`;
|
||||
}
|
||||
|
||||
function updateDetailPopup() {
|
||||
const { detailResult, detailErrorRow, detailError, detailTime } = $cache;
|
||||
if (!detailResult) return;
|
||||
|
||||
const elapsed = currentResult.startTime
|
||||
? ((Date.now() - currentResult.startTime) / 1000).toFixed(1)
|
||||
: '-';
|
||||
|
||||
const isSuccess = currentState === FloatState.SUCCESS;
|
||||
const isPartial = currentState === FloatState.PARTIAL;
|
||||
const isError = currentState === FloatState.ERROR;
|
||||
|
||||
if (isSuccess || isPartial) {
|
||||
detailResult.textContent = `${currentResult.success}/${currentResult.total} 成功`;
|
||||
detailResult.className = `nd-detail-value ${isSuccess ? 'success' : 'warning'}`;
|
||||
detailErrorRow.style.display = isPartial ? 'flex' : 'none';
|
||||
if (isPartial) detailError.textContent = `${currentResult.total - currentResult.success} 张失败`;
|
||||
} else if (isError) {
|
||||
detailResult.textContent = '生成失败';
|
||||
detailResult.className = 'nd-detail-value error';
|
||||
detailErrorRow.style.display = 'flex';
|
||||
detailError.textContent = currentResult.error?.desc || '未知错误';
|
||||
}
|
||||
|
||||
detailTime.textContent = `${elapsed}s`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 拖拽与点击
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function onPointerDown(e) {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
dragState = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startLeft: floatEl.getBoundingClientRect().left,
|
||||
startTop: floatEl.getBoundingClientRect().top,
|
||||
pointerId: e.pointerId,
|
||||
moved: false,
|
||||
originalTarget: e.target
|
||||
};
|
||||
|
||||
try { e.currentTarget.setPointerCapture(e.pointerId); } catch {}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!dragState || dragState.pointerId !== e.pointerId) return;
|
||||
|
||||
const dx = e.clientX - dragState.startX;
|
||||
const dy = e.clientY - dragState.startY;
|
||||
|
||||
if (!dragState.moved && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||||
dragState.moved = true;
|
||||
}
|
||||
|
||||
if (dragState.moved) {
|
||||
const w = floatEl.offsetWidth || 88;
|
||||
const h = floatEl.offsetHeight || 36;
|
||||
floatEl.style.left = `${Math.max(0, Math.min(dragState.startLeft + dx, window.innerWidth - w))}px`;
|
||||
floatEl.style.top = `${Math.max(0, Math.min(dragState.startTop + dy, window.innerHeight - h))}px`;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
if (!dragState || dragState.pointerId !== e.pointerId) return;
|
||||
|
||||
const { moved, originalTarget } = dragState;
|
||||
|
||||
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
|
||||
dragState = null;
|
||||
|
||||
if (moved) {
|
||||
savePosition();
|
||||
} else {
|
||||
routeClick(originalTarget);
|
||||
}
|
||||
}
|
||||
|
||||
function routeClick(target) {
|
||||
if (target.closest('#nd-btn-draw')) {
|
||||
handleDrawClick();
|
||||
} else if (target.closest('#nd-btn-menu')) {
|
||||
floatEl.classList.remove('show-detail');
|
||||
if (!floatEl.classList.contains('expanded')) {
|
||||
refreshPresetSelect();
|
||||
}
|
||||
floatEl.classList.toggle('expanded');
|
||||
} else if (target.closest('#nd-layer-active')) {
|
||||
if ([FloatState.SUCCESS, FloatState.PARTIAL, FloatState.ERROR].includes(currentState)) {
|
||||
updateDetailPopup();
|
||||
floatEl.classList.toggle('show-detail');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 核心操作
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function handleDrawClick() {
|
||||
if (currentState !== FloatState.IDLE) return;
|
||||
|
||||
const messageId = findLastAIMessageId();
|
||||
if (messageId < 0) {
|
||||
toastr?.warning?.('没有可配图的AI消息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await generateAndInsertImages({
|
||||
messageId,
|
||||
onStateChange: (state, data) => {
|
||||
switch (state) {
|
||||
case 'llm':
|
||||
setState(FloatState.LLM);
|
||||
break;
|
||||
case 'gen':
|
||||
setState(FloatState.GEN, data);
|
||||
break;
|
||||
case 'progress':
|
||||
setState(FloatState.GEN, data); // 用 GEN 状态显示进度
|
||||
break;
|
||||
case 'cooldown':
|
||||
setState(FloatState.COOLDOWN, data);
|
||||
break;
|
||||
case 'success':
|
||||
setState(data.success === data.total ? FloatState.SUCCESS : FloatState.PARTIAL, data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[NovelDraw]', e);
|
||||
setState(FloatState.ERROR, { error: classifyError(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 预设管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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 refreshPresetSelect() {
|
||||
if (!$cache.presetSelect) return;
|
||||
$cache.presetSelect.innerHTML = buildPresetOptions();
|
||||
}
|
||||
|
||||
function handlePresetChange(e) {
|
||||
const presetId = e.target.value;
|
||||
if (!presetId) return;
|
||||
|
||||
const settings = getSettings();
|
||||
settings.selectedParamsPresetId = presetId;
|
||||
saveSettings(settings);
|
||||
}
|
||||
|
||||
export function updateAutoModeUI() {
|
||||
if (!floatEl) return;
|
||||
const isAuto = getSettings().mode === 'auto';
|
||||
floatEl.classList.toggle('auto-on', isAuto);
|
||||
|
||||
const menuDot = floatEl.querySelector('#nd-menu-auto-dot');
|
||||
menuDot?.classList.toggle('active', isAuto);
|
||||
}
|
||||
|
||||
function handleAutoToggle() {
|
||||
const settings = getSettings();
|
||||
settings.mode = settings.mode === 'auto' ? 'manual' : 'auto';
|
||||
saveSettings(settings);
|
||||
updateAutoModeUI();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 创建与销毁
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function createFloatingPanel() {
|
||||
if (floatEl) return;
|
||||
|
||||
injectStyles();
|
||||
|
||||
const settings = getSettings();
|
||||
const isAuto = settings.mode === 'auto';
|
||||
|
||||
floatEl = document.createElement('div');
|
||||
floatEl.className = `nd-float${isAuto ? ' auto-on' : ''}`;
|
||||
floatEl.id = 'nd-floating-panel';
|
||||
|
||||
floatEl.innerHTML = `
|
||||
<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" id="nd-detail-result">-</span>
|
||||
</div>
|
||||
<div class="nd-detail-row" id="nd-detail-error-row" style="display:none">
|
||||
<span class="nd-detail-icon">💡</span>
|
||||
<span class="nd-detail-label">原因</span>
|
||||
<span class="nd-detail-value error" id="nd-detail-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" id="nd-detail-time">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nd-menu">
|
||||
<div class="nd-menu-header">画风预设</div>
|
||||
<div class="nd-preset-row">
|
||||
<select class="nd-preset-select" id="nd-preset-select">
|
||||
${buildPresetOptions()}
|
||||
</select>
|
||||
</div>
|
||||
<div class="nd-menu-divider"></div>
|
||||
<div class="nd-menu-item" id="nd-menu-auto">
|
||||
<span class="nd-item-icon">🔄</span>
|
||||
<span>自动配图</span>
|
||||
<span class="nd-menu-dot${isAuto ? ' active' : ''}" id="nd-menu-auto-dot"></span>
|
||||
</div>
|
||||
<div class="nd-menu-divider"></div>
|
||||
<div class="nd-menu-item" id="nd-menu-settings">
|
||||
<span class="nd-item-icon">⚙️</span>
|
||||
<span>设置</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nd-capsule">
|
||||
<div class="nd-inner">
|
||||
<div class="nd-layer nd-layer-idle">
|
||||
<button class="nd-btn-draw" id="nd-btn-draw" title="点击生成配图">
|
||||
<span>🎨</span>
|
||||
<span class="nd-auto-dot"></span>
|
||||
</button>
|
||||
<div class="nd-sep"></div>
|
||||
<button class="nd-btn-menu" id="nd-btn-menu" title="展开菜单">
|
||||
<span class="nd-arrow">▲</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="nd-layer nd-layer-active" id="nd-layer-active">
|
||||
<span id="nd-status-icon">⏳</span>
|
||||
<span id="nd-status-text">分析</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(floatEl);
|
||||
cacheDOM();
|
||||
applyPosition();
|
||||
bindEvents();
|
||||
|
||||
window.addEventListener('resize', applyPosition);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
const capsule = $cache.capsule;
|
||||
if (!capsule) return;
|
||||
|
||||
capsule.addEventListener('pointerdown', onPointerDown, { passive: false });
|
||||
capsule.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
capsule.addEventListener('pointerup', onPointerUp, { passive: false });
|
||||
capsule.addEventListener('pointercancel', onPointerUp, { passive: false });
|
||||
|
||||
$cache.presetSelect?.addEventListener('change', handlePresetChange);
|
||||
|
||||
floatEl.querySelector('#nd-menu-auto')?.addEventListener('click', handleAutoToggle);
|
||||
floatEl.querySelector('#nd-menu-settings')?.addEventListener('click', () => {
|
||||
floatEl.classList.remove('expanded');
|
||||
openNovelDrawSettings();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!floatEl.contains(e.target)) {
|
||||
floatEl.classList.remove('expanded', 'show-detail');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function destroyFloatingPanel() {
|
||||
clearCooldownTimer();
|
||||
|
||||
if (autoResetTimer) {
|
||||
clearTimeout(autoResetTimer);
|
||||
autoResetTimer = null;
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', applyPosition);
|
||||
|
||||
floatEl?.remove();
|
||||
floatEl = null;
|
||||
dragState = null;
|
||||
currentState = FloatState.IDLE;
|
||||
$cache = {};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 导出
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export { FloatState, setState, updateProgress, refreshPresetSelect };
|
||||
878
modules/novel-draw/gallery-cache.js
Normal file
878
modules/novel-draw/gallery-cache.js
Normal file
@@ -0,0 +1,878 @@
|
||||
// gallery-cache.js
|
||||
// 画廊和缓存管理模块
|
||||
|
||||
import { getContext } from "../../../../../extensions.js";
|
||||
import { saveBase64AsFile } from "../../../../../utils.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 常量
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const DB_NAME = 'xb_novel_draw_previews';
|
||||
const DB_STORE = 'previews';
|
||||
const DB_SELECTIONS_STORE = 'selections';
|
||||
const DB_VERSION = 2;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 状态
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let db = null;
|
||||
let dbOpening = null;
|
||||
let galleryOverlayCreated = false;
|
||||
let currentGalleryData = null;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 日志
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function log(...args) {
|
||||
console.log('[GalleryCache]', ...args);
|
||||
}
|
||||
|
||||
function logDbState(label) {
|
||||
log(label, {
|
||||
dbExists: !!db,
|
||||
dbOpening: !!dbOpening,
|
||||
dbName: db?.name,
|
||||
dbVersion: db?.version,
|
||||
stores: db ? [...db.objectStoreNames] : null
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function getChatCharacterName() {
|
||||
const ctx = getContext();
|
||||
if (ctx.groupId) return String(ctx.groups?.[ctx.groupId]?.id ?? 'group');
|
||||
return String(ctx.characters?.[ctx.characterId]?.name || 'character');
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success', duration = 2500) {
|
||||
const colors = { success: 'rgba(62,207,142,0.95)', error: 'rgba(248,113,113,0.95)', info: 'rgba(212,165,116,0.95)' };
|
||||
const toast = document.createElement('div');
|
||||
toast.textContent = message;
|
||||
toast.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:' + (colors[type] || colors.info) + ';color:#fff;padding:10px 20px;border-radius:8px;font-size:13px;z-index:99999;animation:fadeInOut ' + (duration/1000) + 's ease-in-out;max-width:80vw;text-align:center;word-break:break-all';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(function() { toast.remove(); }, duration);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// IndexedDB 操作
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function isDbValid() {
|
||||
if (!db) {
|
||||
log('isDbValid: db is null');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const valid = db.objectStoreNames.length > 0;
|
||||
log('isDbValid:', valid);
|
||||
return valid;
|
||||
} catch (e) {
|
||||
log('isDbValid: error', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openDB() {
|
||||
logDbState('openDB called');
|
||||
|
||||
if (dbOpening) {
|
||||
log('openDB: waiting for existing open...');
|
||||
return dbOpening;
|
||||
}
|
||||
|
||||
if (isDbValid()) {
|
||||
if (db.objectStoreNames.contains(DB_SELECTIONS_STORE)) {
|
||||
log('openDB: reusing existing connection');
|
||||
return db;
|
||||
}
|
||||
log('openDB: missing store, closing...');
|
||||
try {
|
||||
db.close();
|
||||
} catch (e) {
|
||||
log('openDB: close error', e.message);
|
||||
}
|
||||
db = null;
|
||||
}
|
||||
|
||||
log('openDB: creating new connection...');
|
||||
|
||||
dbOpening = new Promise(function(resolve, reject) {
|
||||
var request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = function() {
|
||||
log('openDB: onerror', request.error);
|
||||
dbOpening = null;
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = function() {
|
||||
db = request.result;
|
||||
log('openDB: success, version:', db.version);
|
||||
|
||||
db.onclose = function() {
|
||||
log('openDB: onclose event!');
|
||||
db = null;
|
||||
};
|
||||
|
||||
db.onerror = function(e) {
|
||||
log('openDB: db onerror', e);
|
||||
};
|
||||
|
||||
db.onversionchange = function() {
|
||||
log('openDB: onversionchange, closing...');
|
||||
db.close();
|
||||
db = null;
|
||||
};
|
||||
|
||||
dbOpening = null;
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = function(e) {
|
||||
log('openDB: upgrade', e.oldVersion, '->', e.newVersion);
|
||||
var database = e.target.result;
|
||||
|
||||
if (!database.objectStoreNames.contains(DB_STORE)) {
|
||||
log('openDB: creating', DB_STORE);
|
||||
var store = database.createObjectStore(DB_STORE, { keyPath: 'imgId' });
|
||||
['messageId', 'chatId', 'timestamp', 'slotId'].forEach(function(idx) {
|
||||
store.createIndex(idx, idx);
|
||||
});
|
||||
}
|
||||
|
||||
if (!database.objectStoreNames.contains(DB_SELECTIONS_STORE)) {
|
||||
log('openDB: creating', DB_SELECTIONS_STORE);
|
||||
database.createObjectStore(DB_SELECTIONS_STORE, { keyPath: 'slotId' });
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return dbOpening;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 选中状态管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function setSlotSelection(slotId, imgId) {
|
||||
log('setSlotSelection:', slotId, imgId);
|
||||
var database = await openDB();
|
||||
logDbState('setSlotSelection got db');
|
||||
if (!database.objectStoreNames.contains(DB_SELECTIONS_STORE)) {
|
||||
log('setSlotSelection: no store');
|
||||
return;
|
||||
}
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var tx = database.transaction(DB_SELECTIONS_STORE, 'readwrite');
|
||||
tx.objectStore(DB_SELECTIONS_STORE).put({ slotId: slotId, selectedImgId: imgId, timestamp: Date.now() });
|
||||
tx.oncomplete = function() { log('setSlotSelection: done'); resolve(); };
|
||||
tx.onerror = function() { log('setSlotSelection: error', tx.error); reject(tx.error); };
|
||||
} catch (e) {
|
||||
log('setSlotSelection: tx error', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSlotSelection(slotId) {
|
||||
log('getSlotSelection:', slotId);
|
||||
var database = await openDB();
|
||||
if (!database.objectStoreNames.contains(DB_SELECTIONS_STORE)) return null;
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var tx = database.transaction(DB_SELECTIONS_STORE, 'readonly');
|
||||
var request = tx.objectStore(DB_SELECTIONS_STORE).get(slotId);
|
||||
request.onsuccess = function() { resolve(request.result?.selectedImgId || null); };
|
||||
request.onerror = function() { reject(request.error); };
|
||||
} catch (e) {
|
||||
log('getSlotSelection: tx error', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSlotSelection(slotId) {
|
||||
log('clearSlotSelection:', slotId);
|
||||
var database = await openDB();
|
||||
if (!database.objectStoreNames.contains(DB_SELECTIONS_STORE)) return;
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var tx = database.transaction(DB_SELECTIONS_STORE, 'readwrite');
|
||||
tx.objectStore(DB_SELECTIONS_STORE).delete(slotId);
|
||||
tx.oncomplete = resolve;
|
||||
tx.onerror = function() { reject(tx.error); };
|
||||
} catch (e) {
|
||||
log('clearSlotSelection: tx error', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 预览存储
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function storePreview(opts) {
|
||||
var imgId = opts.imgId;
|
||||
var slotId = opts.slotId;
|
||||
var messageId = opts.messageId;
|
||||
var base64 = opts.base64 || null;
|
||||
var tags = opts.tags;
|
||||
var positive = opts.positive;
|
||||
var savedUrl = opts.savedUrl || null;
|
||||
var status = opts.status || 'success';
|
||||
var errorType = opts.errorType || null;
|
||||
var errorMessage = opts.errorMessage || null;
|
||||
|
||||
log('storePreview:', imgId);
|
||||
var database = await openDB();
|
||||
logDbState('storePreview got db');
|
||||
var ctx = getContext();
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var tx = database.transaction(DB_STORE, 'readwrite');
|
||||
tx.objectStore(DB_STORE).put({
|
||||
imgId: imgId,
|
||||
slotId: slotId || imgId,
|
||||
messageId: messageId,
|
||||
chatId: ctx.chatId || (ctx.characterId || 'unknown'),
|
||||
characterName: getChatCharacterName(),
|
||||
base64: base64,
|
||||
tags: tags,
|
||||
positive: positive,
|
||||
savedUrl: savedUrl,
|
||||
status: status,
|
||||
errorType: errorType,
|
||||
errorMessage: errorMessage,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
tx.oncomplete = function() { log('storePreview: done'); resolve(); };
|
||||
tx.onerror = function() { log('storePreview: error', tx.error); reject(tx.error); };
|
||||
} catch (e) {
|
||||
log('storePreview: tx error', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function storeFailedPlaceholder(opts) {
|
||||
var imgId = 'failed-' + opts.slotId + '-' + Date.now();
|
||||
return storePreview({
|
||||
imgId: imgId,
|
||||
slotId: opts.slotId,
|
||||
messageId: opts.messageId,
|
||||
base64: null,
|
||||
tags: opts.tags,
|
||||
positive: opts.positive,
|
||||
status: 'failed',
|
||||
errorType: opts.errorType,
|
||||
errorMessage: opts.errorMessage
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPreview(imgId) {
|
||||
log('getPreview:', imgId);
|
||||
var database = await openDB();
|
||||
logDbState('getPreview got db');
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var tx = database.transaction(DB_STORE, 'readonly');
|
||||
var request = tx.objectStore(DB_STORE).get(imgId);
|
||||
request.onsuccess = function() {
|
||||
log('getPreview: found:', !!request.result);
|
||||
resolve(request.result);
|
||||
};
|
||||
request.onerror = function() {
|
||||
log('getPreview: error', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
} catch (e) {
|
||||
log('getPreview: tx error', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPreviewsBySlot(slotId) {
|
||||
log('getPreviewsBySlot:', slotId);
|
||||
var database = await openDB();
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var tx = database.transaction(DB_STORE, 'readonly');
|
||||
var store = tx.objectStore(DB_STORE);
|
||||
|
||||
if (store.indexNames.contains('slotId')) {
|
||||
var index = store.index('slotId');
|
||||
var request = index.getAll(slotId);
|
||||
request.onsuccess = function() {
|
||||
var results = request.result || [];
|
||||
if (results.length === 0) {
|
||||
var allRequest = store.getAll();
|
||||
allRequest.onsuccess = function() {
|
||||
var allRecords = allRequest.result || [];
|
||||
results = allRecords.filter(function(r) {
|
||||
return r.slotId === slotId || r.imgId === slotId || (!r.slotId && r.imgId === slotId);
|
||||
});
|
||||
results.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||
resolve(results);
|
||||
};
|
||||
allRequest.onerror = function() { reject(allRequest.error); };
|
||||
} else {
|
||||
results.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
request.onerror = function() { reject(request.error); };
|
||||
} else {
|
||||
var request2 = store.getAll();
|
||||
request2.onsuccess = function() {
|
||||
var allRecords = request2.result || [];
|
||||
var results = allRecords.filter(function(r) { return r.slotId === slotId || r.imgId === slotId; });
|
||||
results.sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||
resolve(results);
|
||||
};
|
||||
request2.onerror = function() { reject(request2.error); };
|
||||
}
|
||||
} catch (e) {
|
||||
log('getPreviewsBySlot: tx error', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDisplayPreviewForSlot(slotId) {
|
||||
var previews = await getPreviewsBySlot(slotId);
|
||||
if (!previews.length) return { preview: null, historyCount: 0, hasData: false, isFailed: false };
|
||||
|
||||
var successPreviews = previews.filter(function(p) { return p.status !== 'failed' && p.base64; });
|
||||
var failedPreviews = previews.filter(function(p) { return p.status === 'failed' || !p.base64; });
|
||||
|
||||
if (successPreviews.length === 0) {
|
||||
var latestFailed = failedPreviews[0];
|
||||
return {
|
||||
preview: latestFailed,
|
||||
historyCount: 0,
|
||||
hasData: false,
|
||||
isFailed: true,
|
||||
failedInfo: {
|
||||
tags: latestFailed?.tags || '',
|
||||
positive: latestFailed?.positive || '',
|
||||
errorType: latestFailed?.errorType,
|
||||
errorMessage: latestFailed?.errorMessage
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var selectedImgId = await getSlotSelection(slotId);
|
||||
if (selectedImgId) {
|
||||
var selected = successPreviews.find(function(p) { return p.imgId === selectedImgId; });
|
||||
if (selected) {
|
||||
return { preview: selected, historyCount: successPreviews.length, hasData: true, isFailed: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { preview: successPreviews[0], historyCount: successPreviews.length, hasData: true, isFailed: false };
|
||||
}
|
||||
|
||||
export async function getLatestPreviewForSlot(slotId) {
|
||||
var result = await getDisplayPreviewForSlot(slotId);
|
||||
return result.preview;
|
||||
}
|
||||
|
||||
export async function deletePreview(imgId) {
|
||||
log('deletePreview:', imgId);
|
||||
var database = await openDB();
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var tx = database.transaction(DB_STORE, 'readwrite');
|
||||
tx.objectStore(DB_STORE).delete(imgId);
|
||||
tx.oncomplete = function() { log('deletePreview: done'); resolve(); };
|
||||
tx.onerror = function() { reject(tx.error); };
|
||||
} catch (e) {
|
||||
log('deletePreview: tx error', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteFailedRecordsForSlot(slotId) {
|
||||
var previews = await getPreviewsBySlot(slotId);
|
||||
var failedRecords = previews.filter(function(p) { return p.status === 'failed' || !p.base64; });
|
||||
for (var i = 0; i < failedRecords.length; i++) {
|
||||
await deletePreview(failedRecords[i].imgId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePreviewSavedUrl(imgId, savedUrl) {
|
||||
log('updatePreviewSavedUrl:', imgId, savedUrl);
|
||||
var database = await openDB();
|
||||
logDbState('updatePreviewSavedUrl got db');
|
||||
|
||||
var preview = await getPreview(imgId);
|
||||
if (!preview) {
|
||||
log('updatePreviewSavedUrl: not found');
|
||||
return;
|
||||
}
|
||||
|
||||
preview.savedUrl = savedUrl;
|
||||
|
||||
log('updatePreviewSavedUrl: re-getting db for write...');
|
||||
database = await openDB();
|
||||
logDbState('updatePreviewSavedUrl got db again');
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var tx = database.transaction(DB_STORE, 'readwrite');
|
||||
tx.objectStore(DB_STORE).put(preview);
|
||||
tx.oncomplete = function() { log('updatePreviewSavedUrl: done'); resolve(); };
|
||||
tx.onerror = function() { log('updatePreviewSavedUrl: error', tx.error); reject(tx.error); };
|
||||
} catch (e) {
|
||||
log('updatePreviewSavedUrl: tx error', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCacheStats() {
|
||||
log('getCacheStats');
|
||||
var database = await openDB();
|
||||
return new Promise(function(resolve) {
|
||||
try {
|
||||
var tx = database.transaction(DB_STORE, 'readonly');
|
||||
var store = tx.objectStore(DB_STORE);
|
||||
var countReq = store.count();
|
||||
var totalSize = 0;
|
||||
var successCount = 0;
|
||||
var failedCount = 0;
|
||||
|
||||
store.openCursor().onsuccess = function(e) {
|
||||
var cursor = e.target.result;
|
||||
if (cursor) {
|
||||
totalSize += (cursor.value.base64?.length || 0) * 0.75;
|
||||
if (cursor.value.status === 'failed' || !cursor.value.base64) {
|
||||
failedCount++;
|
||||
} else {
|
||||
successCount++;
|
||||
}
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
tx.oncomplete = function() {
|
||||
resolve({
|
||||
count: countReq.result || 0,
|
||||
successCount: successCount,
|
||||
failedCount: failedCount,
|
||||
sizeBytes: Math.round(totalSize),
|
||||
sizeMB: (totalSize / 1024 / 1024).toFixed(2)
|
||||
});
|
||||
};
|
||||
} catch (e) {
|
||||
log('getCacheStats: error', e.message);
|
||||
resolve({ count: 0, successCount: 0, failedCount: 0, sizeBytes: 0, sizeMB: '0' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearExpiredCache(cacheDays) {
|
||||
cacheDays = cacheDays || 3;
|
||||
log('clearExpiredCache:', cacheDays, 'days');
|
||||
var cutoff = Date.now() - cacheDays * 24 * 60 * 60 * 1000;
|
||||
var database = await openDB();
|
||||
var deleted = 0;
|
||||
return new Promise(function(resolve) {
|
||||
try {
|
||||
var tx = database.transaction(DB_STORE, 'readwrite');
|
||||
var store = tx.objectStore(DB_STORE);
|
||||
store.openCursor().onsuccess = function(e) {
|
||||
var cursor = e.target.result;
|
||||
if (cursor) {
|
||||
var record = cursor.value;
|
||||
var isExpiredUnsaved = record.timestamp < cutoff && !record.savedUrl;
|
||||
var isFailed = record.status === 'failed' || !record.base64;
|
||||
if (isExpiredUnsaved || (isFailed && record.timestamp < cutoff)) {
|
||||
cursor.delete();
|
||||
deleted++;
|
||||
}
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
tx.oncomplete = function() {
|
||||
log('clearExpiredCache: deleted', deleted);
|
||||
resolve(deleted);
|
||||
};
|
||||
} catch (e) {
|
||||
log('clearExpiredCache: error', e.message);
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearAllCache() {
|
||||
log('clearAllCache');
|
||||
var database = await openDB();
|
||||
return new Promise(function(resolve, reject) {
|
||||
try {
|
||||
var stores = [DB_STORE];
|
||||
if (database.objectStoreNames.contains(DB_SELECTIONS_STORE)) {
|
||||
stores.push(DB_SELECTIONS_STORE);
|
||||
}
|
||||
var tx = database.transaction(stores, 'readwrite');
|
||||
tx.objectStore(DB_STORE).clear();
|
||||
if (stores.length > 1) {
|
||||
tx.objectStore(DB_SELECTIONS_STORE).clear();
|
||||
}
|
||||
tx.oncomplete = function() { log('clearAllCache: done'); resolve(); };
|
||||
tx.onerror = function() { reject(tx.error); };
|
||||
} catch (e) {
|
||||
log('clearAllCache: error', e.message);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getGallerySummary() {
|
||||
log('getGallerySummary');
|
||||
var database = await openDB();
|
||||
return new Promise(function(resolve) {
|
||||
try {
|
||||
var tx = database.transaction(DB_STORE, 'readonly');
|
||||
var store = tx.objectStore(DB_STORE);
|
||||
var request = store.getAll();
|
||||
|
||||
request.onsuccess = function() {
|
||||
var results = request.result || [];
|
||||
var summary = {};
|
||||
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
var item = results[i];
|
||||
if (item.status === 'failed' || !item.base64) continue;
|
||||
|
||||
var charName = item.characterName || 'Unknown';
|
||||
if (!summary[charName]) {
|
||||
summary[charName] = { count: 0, totalSize: 0, slots: {}, latestTimestamp: 0 };
|
||||
}
|
||||
|
||||
var slotId = item.slotId || item.imgId;
|
||||
if (!summary[charName].slots[slotId]) {
|
||||
summary[charName].slots[slotId] = {
|
||||
count: 0, hasSaved: false, latestTimestamp: 0, latestImgId: null,
|
||||
};
|
||||
}
|
||||
|
||||
var slot = summary[charName].slots[slotId];
|
||||
slot.count++;
|
||||
if (item.savedUrl) slot.hasSaved = true;
|
||||
if (item.timestamp > slot.latestTimestamp) {
|
||||
slot.latestTimestamp = item.timestamp;
|
||||
slot.latestImgId = item.imgId;
|
||||
}
|
||||
|
||||
summary[charName].count++;
|
||||
summary[charName].totalSize += (item.base64?.length || 0) * 0.75;
|
||||
if (item.timestamp > summary[charName].latestTimestamp) {
|
||||
summary[charName].latestTimestamp = item.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
resolve(summary);
|
||||
};
|
||||
request.onerror = function() { resolve({}); };
|
||||
} catch (e) {
|
||||
log('getGallerySummary: error', e.message);
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCharacterPreviews(charName) {
|
||||
log('getCharacterPreviews:', charName);
|
||||
var database = await openDB();
|
||||
return new Promise(function(resolve) {
|
||||
try {
|
||||
var tx = database.transaction(DB_STORE, 'readonly');
|
||||
var store = tx.objectStore(DB_STORE);
|
||||
var request = store.getAll();
|
||||
|
||||
request.onsuccess = function() {
|
||||
var results = request.result || [];
|
||||
var slots = {};
|
||||
|
||||
for (var i = 0; i < results.length; i++) {
|
||||
var item = results[i];
|
||||
if ((item.characterName || 'Unknown') !== charName) continue;
|
||||
if (item.status === 'failed' || !item.base64) continue;
|
||||
|
||||
var slotId = item.slotId || item.imgId;
|
||||
if (!slots[slotId]) slots[slotId] = [];
|
||||
slots[slotId].push(item);
|
||||
}
|
||||
|
||||
for (var sid in slots) {
|
||||
slots[sid].sort(function(a, b) { return b.timestamp - a.timestamp; });
|
||||
}
|
||||
|
||||
resolve(slots);
|
||||
};
|
||||
request.onerror = function() { resolve({}); };
|
||||
} catch (e) {
|
||||
log('getCharacterPreviews: error', e.message);
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 小画廊 UI
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function ensureGalleryStyles() {
|
||||
if (document.getElementById('nd-gallery-styles')) return;
|
||||
var style = document.createElement('style');
|
||||
style.id = 'nd-gallery-styles';
|
||||
style.textContent = '#nd-gallery-overlay{position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:100000;display:none;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px)}#nd-gallery-overlay.visible{display:flex;flex-direction:column;align-items:center;justify-content:center}.nd-gallery-close{position:absolute;top:16px;right:16px;width:40px;height:40px;border:none;background:rgba(255,255,255,0.1);border-radius:50%;color:#fff;font-size:20px;cursor:pointer;z-index:10}.nd-gallery-close:hover{background:rgba(255,255,255,0.2)}.nd-gallery-main{display:flex;align-items:center;gap:16px;max-width:90vw;max-height:70vh}.nd-gallery-nav{width:48px;height:48px;border:none;background:rgba(255,255,255,0.1);border-radius:50%;color:#fff;font-size:24px;cursor:pointer;flex-shrink:0}.nd-gallery-nav:hover{background:rgba(255,255,255,0.2)}.nd-gallery-nav:disabled{opacity:0.3;cursor:not-allowed}.nd-gallery-img-wrap{position:relative;max-width:calc(90vw - 140px);max-height:70vh}.nd-gallery-img{max-width:100%;max-height:70vh;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,0.5)}.nd-gallery-saved-badge{position:absolute;top:12px;left:12px;background:rgba(62,207,142,0.9);padding:4px 10px;border-radius:6px;font-size:11px;color:#fff;font-weight:600}.nd-gallery-thumbs{display:flex;gap:8px;margin-top:20px;padding:12px;background:rgba(0,0,0,0.3);border-radius:12px;max-width:90vw;overflow-x:auto}.nd-gallery-thumb{width:64px;height:64px;border-radius:8px;object-fit:cover;cursor:pointer;border:2px solid transparent;opacity:0.6;transition:all 0.15s;flex-shrink:0}.nd-gallery-thumb:hover{opacity:0.9}.nd-gallery-thumb.active{border-color:#d4a574;opacity:1}.nd-gallery-thumb.saved{border-color:rgba(62,207,142,0.8)}.nd-gallery-actions{display:flex;gap:12px;margin-top:16px}.nd-gallery-btn{padding:10px 20px;border:1px solid rgba(255,255,255,0.2);border-radius:8px;background:rgba(255,255,255,0.1);color:#fff;font-size:13px;cursor:pointer;transition:all 0.15s}.nd-gallery-btn:hover{background:rgba(255,255,255,0.2)}.nd-gallery-btn.primary{background:rgba(212,165,116,0.3);border-color:rgba(212,165,116,0.5)}.nd-gallery-btn.danger{color:#f87171;border-color:rgba(248,113,113,0.3)}.nd-gallery-btn.danger:hover{background:rgba(248,113,113,0.15)}.nd-gallery-info{text-align:center;margin-top:12px;font-size:12px;color:rgba(255,255,255,0.6)}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function createGalleryOverlay() {
|
||||
if (galleryOverlayCreated) return;
|
||||
galleryOverlayCreated = true;
|
||||
ensureGalleryStyles();
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.id = 'nd-gallery-overlay';
|
||||
overlay.innerHTML = '<button class="nd-gallery-close" id="nd-gallery-close">\u2715</button><div class="nd-gallery-main"><button class="nd-gallery-nav" id="nd-gallery-prev">\u2039</button><div class="nd-gallery-img-wrap"><img class="nd-gallery-img" id="nd-gallery-img" src="" alt=""><div class="nd-gallery-saved-badge" id="nd-gallery-saved-badge" style="display:none">\u5DF2\u4FDD\u5B58</div></div><button class="nd-gallery-nav" id="nd-gallery-next">\u203A</button></div><div class="nd-gallery-thumbs" id="nd-gallery-thumbs"></div><div class="nd-gallery-actions" id="nd-gallery-actions"><button class="nd-gallery-btn primary" id="nd-gallery-use">\u4F7F\u7528\u6B64\u56FE</button><button class="nd-gallery-btn" id="nd-gallery-save">\uD83D\uDCBE \u4FDD\u5B58\u5230\u670D\u52A1\u5668</button><button class="nd-gallery-btn danger" id="nd-gallery-delete">\uD83D\uDDD1\uFE0F \u5220\u9664</button></div><div class="nd-gallery-info" id="nd-gallery-info"></div>';
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('nd-gallery-close').addEventListener('click', closeGallery);
|
||||
document.getElementById('nd-gallery-prev').addEventListener('click', function() { navigateGallery(-1); });
|
||||
document.getElementById('nd-gallery-next').addEventListener('click', function() { navigateGallery(1); });
|
||||
document.getElementById('nd-gallery-use').addEventListener('click', useCurrentGalleryImage);
|
||||
document.getElementById('nd-gallery-save').addEventListener('click', saveCurrentGalleryImage);
|
||||
document.getElementById('nd-gallery-delete').addEventListener('click', deleteCurrentGalleryImage);
|
||||
|
||||
overlay.addEventListener('click', function(e) {
|
||||
if (e.target === overlay) closeGallery();
|
||||
});
|
||||
}
|
||||
|
||||
export async function openGallery(slotId, messageId, callbacks) {
|
||||
callbacks = callbacks || {};
|
||||
log('openGallery:', slotId, messageId);
|
||||
createGalleryOverlay();
|
||||
|
||||
var previews = await getPreviewsBySlot(slotId);
|
||||
var validPreviews = previews.filter(function(p) { return p.status !== 'failed' && p.base64; });
|
||||
|
||||
if (!validPreviews.length) {
|
||||
showToast('\u6CA1\u6709\u627E\u5230\u56FE\u7247\u5386\u53F2', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedImgId = await getSlotSelection(slotId);
|
||||
var startIndex = 0;
|
||||
if (selectedImgId) {
|
||||
var idx = validPreviews.findIndex(function(p) { return p.imgId === selectedImgId; });
|
||||
if (idx >= 0) startIndex = idx;
|
||||
}
|
||||
|
||||
currentGalleryData = { slotId: slotId, messageId: messageId, previews: validPreviews, currentIndex: startIndex, callbacks: callbacks };
|
||||
renderGallery();
|
||||
document.getElementById('nd-gallery-overlay').classList.add('visible');
|
||||
}
|
||||
|
||||
export function closeGallery() {
|
||||
log('closeGallery');
|
||||
var el = document.getElementById('nd-gallery-overlay');
|
||||
if (el) el.classList.remove('visible');
|
||||
currentGalleryData = null;
|
||||
}
|
||||
|
||||
function renderGallery() {
|
||||
if (!currentGalleryData) return;
|
||||
|
||||
var previews = currentGalleryData.previews;
|
||||
var currentIndex = currentGalleryData.currentIndex;
|
||||
var current = previews[currentIndex];
|
||||
if (!current) return;
|
||||
|
||||
var img = document.getElementById('nd-gallery-img');
|
||||
img.src = current.savedUrl || ('data:image/png;base64,' + current.base64);
|
||||
|
||||
document.getElementById('nd-gallery-saved-badge').style.display = current.savedUrl ? 'block' : 'none';
|
||||
|
||||
var reversedPreviews = previews.slice().reverse();
|
||||
var thumbsContainer = document.getElementById('nd-gallery-thumbs');
|
||||
|
||||
thumbsContainer.innerHTML = reversedPreviews.map(function(p, i) {
|
||||
var src = p.savedUrl || ('data:image/png;base64,' + p.base64);
|
||||
var originalIndex = previews.length - 1 - i;
|
||||
var classes = ['nd-gallery-thumb'];
|
||||
if (originalIndex === currentIndex) classes.push('active');
|
||||
if (p.savedUrl) classes.push('saved');
|
||||
return '<img class="' + classes.join(' ') + '" src="' + src + '" data-index="' + originalIndex + '" alt="" loading="lazy">';
|
||||
}).join('');
|
||||
|
||||
thumbsContainer.querySelectorAll('.nd-gallery-thumb').forEach(function(thumb) {
|
||||
thumb.addEventListener('click', function() {
|
||||
currentGalleryData.currentIndex = parseInt(thumb.dataset.index);
|
||||
renderGallery();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('nd-gallery-prev').disabled = currentIndex >= previews.length - 1;
|
||||
document.getElementById('nd-gallery-next').disabled = currentIndex <= 0;
|
||||
|
||||
var saveBtn = document.getElementById('nd-gallery-save');
|
||||
if (current.savedUrl) {
|
||||
saveBtn.textContent = '\u2713 \u5DF2\u4FDD\u5B58';
|
||||
saveBtn.disabled = true;
|
||||
} else {
|
||||
saveBtn.textContent = '\uD83D\uDCBE \u4FDD\u5B58\u5230\u670D\u52A1\u5668';
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
|
||||
var displayVersion = previews.length - currentIndex;
|
||||
var date = new Date(current.timestamp).toLocaleString();
|
||||
document.getElementById('nd-gallery-info').textContent = '\u7248\u672C ' + displayVersion + ' / ' + previews.length + ' \u00B7 ' + date;
|
||||
}
|
||||
|
||||
function navigateGallery(delta) {
|
||||
if (!currentGalleryData) return;
|
||||
var newIndex = currentGalleryData.currentIndex - delta;
|
||||
if (newIndex >= 0 && newIndex < currentGalleryData.previews.length) {
|
||||
currentGalleryData.currentIndex = newIndex;
|
||||
renderGallery();
|
||||
}
|
||||
}
|
||||
|
||||
async function useCurrentGalleryImage() {
|
||||
if (!currentGalleryData) return;
|
||||
log('useCurrentGalleryImage');
|
||||
|
||||
var slotId = currentGalleryData.slotId;
|
||||
var messageId = currentGalleryData.messageId;
|
||||
var previews = currentGalleryData.previews;
|
||||
var currentIndex = currentGalleryData.currentIndex;
|
||||
var callbacks = currentGalleryData.callbacks;
|
||||
var selected = previews[currentIndex];
|
||||
if (!selected) return;
|
||||
|
||||
await setSlotSelection(slotId, selected.imgId);
|
||||
|
||||
if (callbacks.onUse) callbacks.onUse(slotId, messageId, selected, previews.length);
|
||||
closeGallery();
|
||||
showToast('\u5DF2\u5207\u6362\u663E\u793A\u56FE\u7247');
|
||||
}
|
||||
|
||||
async function saveCurrentGalleryImage() {
|
||||
if (!currentGalleryData) return;
|
||||
log('saveCurrentGalleryImage');
|
||||
|
||||
var slotId = currentGalleryData.slotId;
|
||||
var previews = currentGalleryData.previews;
|
||||
var currentIndex = currentGalleryData.currentIndex;
|
||||
var callbacks = currentGalleryData.callbacks;
|
||||
var current = previews[currentIndex];
|
||||
if (!current || current.savedUrl) return;
|
||||
|
||||
try {
|
||||
var charName = current.characterName || getChatCharacterName();
|
||||
var url = await saveBase64AsFile(current.base64, charName, 'novel_' + current.imgId, 'png');
|
||||
await updatePreviewSavedUrl(current.imgId, url);
|
||||
current.savedUrl = url;
|
||||
|
||||
await setSlotSelection(slotId, current.imgId);
|
||||
|
||||
showToast('\u5DF2\u4FDD\u5B58: ' + url, 'success', 4000);
|
||||
renderGallery();
|
||||
if (callbacks.onSave) callbacks.onSave(current.imgId, url);
|
||||
} catch (e) {
|
||||
console.error('[GalleryCache] save failed:', e);
|
||||
showToast('\u4FDD\u5B58\u5931\u8D25: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentGalleryImage() {
|
||||
if (!currentGalleryData) return;
|
||||
log('deleteCurrentGalleryImage');
|
||||
|
||||
var slotId = currentGalleryData.slotId;
|
||||
var messageId = currentGalleryData.messageId;
|
||||
var previews = currentGalleryData.previews;
|
||||
var currentIndex = currentGalleryData.currentIndex;
|
||||
var callbacks = currentGalleryData.callbacks;
|
||||
var current = previews[currentIndex];
|
||||
if (!current) return;
|
||||
|
||||
var msg = current.savedUrl ? '\u786E\u5B9A\u5220\u9664\u8FD9\u6761\u8BB0\u5F55\u5417\uFF1F\u670D\u52A1\u5668\u4E0A\u7684\u56FE\u7247\u6587\u4EF6\u4E0D\u4F1A\u88AB\u5220\u9664\u3002' : '\u786E\u5B9A\u5220\u9664\u8FD9\u5F20\u56FE\u7247\u5417\uFF1F';
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
try {
|
||||
await deletePreview(current.imgId);
|
||||
|
||||
var selectedId = await getSlotSelection(slotId);
|
||||
if (selectedId === current.imgId) {
|
||||
await clearSlotSelection(slotId);
|
||||
}
|
||||
|
||||
previews.splice(currentIndex, 1);
|
||||
|
||||
if (previews.length === 0) {
|
||||
closeGallery();
|
||||
|
||||
if (callbacks.onBecameEmpty) {
|
||||
callbacks.onBecameEmpty(slotId, messageId, {
|
||||
tags: current.tags || '',
|
||||
positive: current.positive || ''
|
||||
});
|
||||
}
|
||||
showToast('\u56FE\u7247\u5DF2\u5220\u9664\uFF0C\u53EF\u70B9\u51FB\u91CD\u8BD5\u91CD\u65B0\u751F\u6210');
|
||||
} else {
|
||||
if (currentGalleryData.currentIndex >= previews.length) {
|
||||
currentGalleryData.currentIndex = previews.length - 1;
|
||||
}
|
||||
renderGallery();
|
||||
if (callbacks.onDelete) callbacks.onDelete(slotId, current.imgId, previews);
|
||||
showToast('\u56FE\u7247\u5DF2\u5220\u9664');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[GalleryCache] delete failed:', e);
|
||||
showToast('\u5220\u9664\u5931\u8D25: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 清理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function destroyGalleryCache() {
|
||||
log('destroyGalleryCache called');
|
||||
console.trace('destroyGalleryCache trace');
|
||||
closeGallery();
|
||||
var el1 = document.getElementById('nd-gallery-overlay');
|
||||
if (el1) el1.remove();
|
||||
var el2 = document.getElementById('nd-gallery-styles');
|
||||
if (el2) el2.remove();
|
||||
galleryOverlayCreated = false;
|
||||
if (db) {
|
||||
try {
|
||||
db.close();
|
||||
log('destroyGalleryCache: closed db');
|
||||
} catch (e) {
|
||||
log('destroyGalleryCache: close error', e.message);
|
||||
}
|
||||
db = null;
|
||||
}
|
||||
dbOpening = null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user