context-scaling / static /index.html
heheyas's picture
Frontend: DiT calls switch to async job pattern
5768c63
Raw
History Blame Contribute Delete
154 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Structured Prompt Studio</title>
<style>
/* ==================== Reset & Variables ==================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #f6f7fb;
--bg-gradient: radial-gradient(1200px 800px at 15% -10%, #eef2ff 0%, transparent 55%),
radial-gradient(1000px 700px at 110% 110%, #fdf4ff 0%, transparent 60%),
#f6f7fb;
--panel-bg: #ffffff;
--panel-bg-subtle: #f8fafc;
--border: #e5e7eb;
--border-light: #eef0f4;
--border-hover: #d1d5db;
--text: #0f172a;
--text-secondary: #64748b;
--text-placeholder: #94a3b8;
--primary: #6366f1;
--primary-hover: #4f46e5;
--primary-light: #eef2ff;
--primary-dark: #4338ca;
--primary-ring: rgba(99, 102, 241, 0.18);
--primary-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
--success: #10b981;
--success-light: #ecfdf5;
--warning: #f59e0b;
--warning-light: #fffbeb;
--danger: #ef4444;
--danger-light: #fef2f2;
--info: #64748b;
--info-light: #f1f5f9;
--shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 2px 8px rgba(15, 23, 42, 0.04);
--shadow-lg: 0 4px 20px rgba(15, 23, 42, 0.08);
--shadow-primary: 0 4px 14px rgba(99, 102, 241, 0.25);
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--transition: all 160ms cubic-bezier(0.4, 0, 0.2, 1);
}
body {
font-family: 'Inter', ui-sans-serif, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji';
background: var(--bg-gradient);
color: var(--text);
height: 100vh;
overflow: hidden;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ==================== App Layout ==================== */
.app {
height: 100vh;
display: flex;
flex-direction: column;
padding: 14px;
gap: 12px;
}
/* ==================== History Bar ==================== */
.history-bar {
flex-shrink: 0;
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
display: flex;
align-items: center;
padding: 0 18px;
height: 52px;
gap: 14px;
}
.history-title {
font-weight: 700;
font-size: 11.5px;
color: var(--text-secondary);
flex-shrink: 0;
user-select: none;
letter-spacing: 0.7px;
text-transform: uppercase;
}
.history-scroll {
flex: 1;
display: flex;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
align-items: center;
padding: 4px 0;
}
.history-scroll::-webkit-scrollbar {
height: 3px;
}
.history-scroll::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.history-empty {
color: var(--text-placeholder);
font-size: 13px;
white-space: nowrap;
}
.history-clear-btn {
flex-shrink: 0;
}
/* History Item */
.history-item {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
transition: var(--transition);
border: 1px solid var(--border);
background: var(--bg);
font-size: 11px;
white-space: nowrap;
}
.history-item:hover {
border-color: var(--primary);
background: var(--primary-light);
}
.history-thumb {
width: 26px;
height: 26px;
border-radius: 3px;
object-fit: cover;
border: 1px solid var(--border);
}
.history-thumb.repeated {
border: 2px solid #333;
}
.history-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 3px;
font-size: 10px;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.history-badge.badge-p { background: var(--primary); }
.history-badge.badge-c { background: var(--warning); }
.history-badge.badge-s { background: var(--success); }
.history-type-label {
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
}
.history-separator {
width: 1px;
height: 22px;
background: var(--border);
flex-shrink: 0;
margin: 0 2px;
}
/* History group tinting — one muted palette with a subtle left-accent bar */
.history-item.hc-0 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #6366f1; }
.history-item.hc-1 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #10b981; }
.history-item.hc-2 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #f59e0b; }
.history-item.hc-3 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #ef4444; }
.history-item.hc-4 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #64748b; }
.history-item.hc-5 { background: var(--panel-bg); border-color: var(--border); border-left: 3px solid #06b6d4; }
.history-item.hc-0:hover,
.history-item.hc-1:hover,
.history-item.hc-2:hover,
.history-item.hc-3:hover,
.history-item.hc-4:hover,
.history-item.hc-5:hover {
background: var(--primary-light);
border-color: var(--primary);
}
/* ==================== Workspace (CSS Grid) ==================== */
.workspace {
flex: 1;
display: grid;
grid-template-columns: 1fr minmax(280px, 0.45fr);
grid-template-rows: 1fr auto auto;
gap: 10px;
min-height: 0;
}
/* Grid placement */
.image-panel { grid-row: 1; grid-column: 1; }
.sp-panel { grid-row: 1; grid-column: 2; }
.params-bar { grid-row: 2; grid-column: 1; }
.gen-sp-btn { grid-row: 2; grid-column: 2; }
.prompt-bar { grid-row: 3; grid-column: 1; }
.gen-image-btn { grid-row: 3; grid-column: 2; }
/* ==================== Panel Base ==================== */
.panel {
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-bottom: 1px solid var(--border-light);
background: var(--panel-bg-subtle);
flex-shrink: 0;
}
.panel-label {
font-weight: 700;
font-size: 11.5px;
color: var(--text-secondary);
letter-spacing: 0.7px;
text-transform: uppercase;
user-select: none;
}
.panel-actions {
display: flex;
gap: 2px;
}
/* Tool Buttons */
.tool-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid transparent;
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition);
}
.tool-btn:hover {
background: var(--primary-light);
color: var(--primary);
border-color: var(--border-light);
}
/* ==================== Image Panel ==================== */
.image-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
.image-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
min-height: 0;
overflow: hidden;
}
/* Dropzone */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
width: calc(100% - 28px);
height: calc(100% - 28px);
margin: 14px;
cursor: pointer;
border: 1.5px dashed var(--border-hover);
border-radius: var(--radius);
transition: var(--transition);
background: var(--panel-bg-subtle);
}
.dropzone:hover,
.dropzone.dragover {
border-color: var(--primary);
background: var(--primary-light);
transform: scale(1.005);
}
.dropzone-icon {
font-size: 44px;
opacity: 0.55;
transition: var(--transition);
}
.dropzone:hover .dropzone-icon {
opacity: 0.85;
transform: translateY(-2px);
}
.dropzone-text {
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
letter-spacing: 0.1px;
}
/* Image Display */
.image-display {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
}
.image-display img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: pointer;
border-radius: 4px;
transition: opacity 0.2s;
}
.image-display img:hover {
opacity: 0.95;
}
/* BBox Overlay */
.bbox-container {
position: absolute;
pointer-events: none;
display: none;
}
.bbox-box {
position: absolute;
border: 2px solid;
pointer-events: auto;
cursor: default;
border-radius: 2px;
}
.bbox-box.focused {
border-width: 3px;
z-index: 10;
}
.bbox-box.active {
border-width: 3px;
z-index: 10;
cursor: move;
}
.bbox-box .bbox-handle {
display: none;
}
.bbox-box.active .bbox-handle {
display: block;
}
.bbox-box .bbox-label {
position: absolute;
top: -20px;
left: -1px;
padding: 1px 6px;
font-size: 10px;
color: white;
border-radius: 3px 3px 0 0;
white-space: nowrap;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
line-height: 16px;
pointer-events: none;
}
/* BBox Resize Handles */
.bbox-handle {
position: absolute;
width: 8px;
height: 8px;
background: white;
border: 2px solid;
border-radius: 2px;
pointer-events: auto;
z-index: 11;
}
.bbox-handle.h-nw { top: -5px; left: -5px; cursor: nw-resize; }
.bbox-handle.h-ne { top: -5px; right: -5px; cursor: ne-resize; }
.bbox-handle.h-sw { bottom: -5px; left: -5px; cursor: sw-resize; }
.bbox-handle.h-se { bottom: -5px; right: -5px; cursor: se-resize; }
.bbox-handle.h-n { top: -5px; left: 50%; margin-left: -4px; cursor: n-resize; }
.bbox-handle.h-s { bottom: -5px; left: 50%; margin-left: -4px; cursor: s-resize; }
.bbox-handle.h-w { top: 50%; margin-top: -4px; left: -5px; cursor: w-resize; }
.bbox-handle.h-e { top: 50%; margin-top: -4px; right: -5px; cursor: e-resize; }
/* ==================== Structured Prompt Panel ==================== */
.sp-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
.sp-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
position: relative;
overflow: hidden;
}
.sp-empty {
text-align: center;
color: var(--text-placeholder);
padding: 20px;
}
.sp-empty-icon {
font-size: 36px;
margin-bottom: 8px;
opacity: 0.5;
}
.sp-empty-hint {
font-size: 12px;
margin-top: 6px;
color: var(--text-placeholder);
}
.sp-area textarea {
width: 100%;
height: 100%;
border: none;
outline: none;
resize: none;
padding: 12px;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', 'Liberation Mono', monospace;
font-size: 12px;
line-height: 1.65;
color: var(--text);
background: transparent;
tab-size: 2;
}
.sp-area textarea::placeholder {
color: var(--text-placeholder);
}
/* ==================== SP Structured View ==================== */
.sp-structured {
width: 100%;
height: 100%;
overflow-y: auto;
padding: 8px;
}
/* Section (collapsible) */
.sp-section {
border: 1px solid var(--border-light);
border-radius: 6px;
margin-bottom: 6px;
overflow: hidden;
background: var(--panel-bg);
}
.sp-section:last-child {
margin-bottom: 0;
}
.sp-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 10px;
cursor: pointer;
user-select: none;
font-size: 12px;
font-weight: 600;
color: var(--text);
transition: var(--transition);
}
.sp-section-header:hover {
background: var(--bg);
}
.sp-section-title {
display: flex;
align-items: center;
gap: 6px;
}
.sp-section-badge {
background: var(--primary);
color: white;
border-radius: 8px;
padding: 0 6px;
font-size: 10px;
font-weight: 700;
line-height: 16px;
min-width: 16px;
text-align: center;
}
.sp-section-actions {
display: flex;
align-items: center;
gap: 4px;
}
.sp-section-add-btn {
width: 20px;
height: 20px;
border: 1px dashed var(--border);
background: transparent;
color: var(--text-secondary);
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: var(--transition);
padding: 0;
line-height: 1;
}
.sp-section-add-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-light);
}
.sp-section-clear-btn {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: var(--text-placeholder);
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
transition: var(--transition);
padding: 0;
opacity: 0;
}
.sp-section-header:hover .sp-section-clear-btn {
opacity: 1;
}
.sp-section-clear-btn:hover {
background: var(--danger-light);
color: var(--danger);
}
.sp-section-arrow {
font-size: 12px;
color: var(--text-secondary);
transition: transform 0.2s;
}
.sp-section.collapsed .sp-section-arrow {
transform: rotate(-90deg);
}
.sp-section-body {
padding: 4px 8px 8px;
overflow: hidden;
}
.sp-section.collapsed .sp-section-body {
display: none;
}
/* Field row */
.sp-field {
display: flex;
align-items: flex-start;
padding: 4px 6px;
border-radius: 4px;
transition: background 0.15s;
gap: 8px;
position: relative;
}
.sp-field:hover {
background: #f5f7fa;
}
.sp-field-key {
font-size: 11px;
font-weight: 600;
color: var(--primary-dark);
min-width: 80px;
flex-shrink: 0;
padding-top: 1px;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
cursor: default;
border-radius: 3px;
transition: background 0.15s;
}
.sp-field-key[contenteditable="true"] {
outline: none;
background: #fff;
border: 1px solid var(--warning);
padding: 0 3px;
box-shadow: 0 0 0 2px rgba(230, 162, 60, 0.12);
user-select: text;
}
.sp-field-value {
flex: 1;
font-size: 12px;
line-height: 1.5;
color: var(--text);
word-break: break-word;
cursor: default;
min-height: 18px;
border-radius: 3px;
transition: background 0.15s;
}
.sp-field-value[contenteditable="true"] {
outline: none;
background: #fff;
border: 1px solid var(--primary);
border-radius: 3px;
padding: 1px 4px;
box-shadow: 0 0 0 2px var(--primary-ring);
}
.sp-field-actions {
display: none;
gap: 2px;
flex-shrink: 0;
align-items: center;
}
.sp-field:hover .sp-field-actions {
display: flex;
}
.sp-field-btn {
width: 20px;
height: 20px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 3px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: var(--transition);
padding: 0;
}
.sp-field-btn:hover {
background: var(--danger-light);
color: var(--danger);
}
.sp-field-btn.edit-btn:hover,
.sp-field-btn.sp-elem-add-btn:hover {
background: var(--primary-light);
color: var(--primary);
}
/* Dict field (nested) */
.sp-dict-block {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.sp-dict-row {
display: flex;
gap: 6px;
padding: 2px 0;
align-items: flex-start;
}
.sp-dict-key {
font-size: 11px;
color: var(--text-secondary);
min-width: 70px;
flex-shrink: 0;
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
cursor: default;
border-radius: 3px;
}
.sp-dict-key[contenteditable="true"] {
outline: none;
background: #fff;
border: 1px solid var(--warning);
padding: 0 3px;
box-shadow: 0 0 0 2px rgba(230, 162, 60, 0.12);
}
.sp-dict-value {
flex: 1;
font-size: 12px;
color: var(--text);
word-break: break-word;
cursor: default;
border-radius: 3px;
}
.sp-dict-value[contenteditable="true"] {
outline: none;
background: #fff;
border: 1px solid var(--primary);
border-radius: 3px;
padding: 0 4px;
box-shadow: 0 0 0 2px var(--primary-ring);
}
/* Element card */
.sp-element {
border: 1px solid var(--border-light);
border-radius: 6px;
margin-bottom: 5px;
overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s;
}
.sp-element:last-child {
margin-bottom: 0;
}
.sp-element:hover {
border-color: var(--border);
}
.sp-element.highlight {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-ring);
}
.sp-element-header {
display: flex;
align-items: center;
padding: 5px 8px;
gap: 6px;
cursor: pointer;
user-select: none;
transition: background 0.15s;
}
.sp-element-header:hover {
background: #f5f7fa;
}
.sp-element-color {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.sp-element-id {
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
font-family: monospace;
flex-shrink: 0;
}
.sp-element-caption {
flex: 1;
font-size: 12px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.sp-element-actions {
display: none;
gap: 2px;
}
.sp-element-header:hover .sp-element-actions {
display: flex;
}
.sp-element-body {
padding: 2px 8px 6px;
border-top: 1px solid var(--border-light);
}
.sp-element.collapsed .sp-element-body {
display: none;
}
/* Position badge (bbox) */
.sp-pos-badge {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--info-light);
padding: 1px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
transition: var(--transition);
}
.sp-pos-badge:hover {
background: var(--primary-light);
color: var(--primary);
}
.sp-pos-badge .pos-icon {
font-size: 10px;
}
.sp-bbox-edit-btn {
margin-left: 4px;
padding: 0 4px;
font-size: 11px;
line-height: 18px;
vertical-align: middle;
border-radius: 3px;
cursor: pointer;
border: 1px solid transparent;
background: transparent;
opacity: 0.5;
transition: var(--transition);
}
.sp-bbox-edit-btn:hover {
opacity: 1;
background: var(--primary-light);
border-color: var(--primary);
}
/* Relationship list */
.sp-rel-list {
list-style: none;
padding: 0;
margin: 0;
}
.sp-rel-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 3px 6px;
border-radius: 4px;
transition: background 0.15s;
font-size: 12px;
color: var(--text);
}
.sp-rel-item:hover {
background: #f5f7fa;
}
.sp-rel-bullet {
color: var(--text-placeholder);
flex-shrink: 0;
}
.sp-rel-text {
flex: 1;
word-break: break-word;
cursor: default;
border-radius: 3px;
}
/* Active state for raw toggle button */
.tool-btn.active {
background: var(--primary-light);
color: var(--primary);
}
/* ==================== Params Bar ==================== */
.params-bar {
display: flex;
align-items: center;
padding: 0 12px;
gap: 10px;
min-height: 46px;
flex-wrap: wrap;
}
.params-label {
font-size: 11.5px;
font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
user-select: none;
letter-spacing: 0.7px;
text-transform: uppercase;
}
.params-separator {
width: 1px;
height: 20px;
background: var(--border);
flex-shrink: 0;
}
.params-spacer {
flex: 1;
}
.params-group {
display: flex;
align-items: center;
gap: 4px;
}
.params-group label {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
user-select: none;
}
.params-group input[type="number"] {
width: 66px;
height: 28px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0 6px;
font-size: 12px;
color: var(--text);
background: var(--panel-bg);
text-align: center;
outline: none;
transition: var(--transition);
font-variant-numeric: tabular-nums;
}
.params-group input[type="number"]:hover:not(:disabled) {
border-color: var(--border-hover);
}
.params-group input[type="number"]:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-ring);
}
.params-group input[type="number"]:disabled {
background: var(--bg);
color: var(--text-placeholder);
cursor: not-allowed;
}
.params-group select {
height: 26px;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 4px;
font-size: 12px;
color: var(--text);
background: var(--panel-bg);
outline: none;
cursor: pointer;
transition: var(--transition);
}
.params-group select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-ring);
}
.params-checkbox {
gap: 3px !important;
}
.params-checkbox input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--primary);
}
/* ==================== Prompt Bar ==================== */
.prompt-bar {
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
min-height: 50px;
}
.prompt-label {
font-size: 11.5px;
font-weight: 700;
color: var(--text-secondary);
white-space: nowrap;
user-select: none;
flex-shrink: 0;
letter-spacing: 0.7px;
text-transform: uppercase;
}
.prompt-bar textarea {
flex: 1;
border: none;
outline: none;
resize: none;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: transparent;
height: 32px;
font-family: inherit;
padding: 4px 0;
}
.prompt-bar textarea::placeholder {
color: var(--text-placeholder);
}
/* ==================== Action Buttons ==================== */
.action-btn {
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-xs);
user-select: none;
background: var(--panel-bg);
color: var(--text);
letter-spacing: 0.15px;
}
.action-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow);
border-color: var(--primary);
color: var(--primary);
}
.action-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Secondary action — Generate SP */
.gen-sp-btn {
min-height: 46px;
font-size: 14px;
}
/* Primary action — the CTA */
.gen-image-btn {
min-height: 52px;
font-size: 14.5px;
font-weight: 600;
background: var(--primary-gradient);
color: #ffffff;
border: 1px solid transparent;
box-shadow: var(--shadow-primary);
}
.gen-image-btn:hover:not(:disabled) {
color: #ffffff;
border-color: transparent;
filter: brightness(1.06);
box-shadow: 0 6px 22px rgba(99, 102, 241, 0.32);
transform: translateY(-1px);
}
.gen-image-btn:active:not(:disabled) {
filter: brightness(0.98);
box-shadow: var(--shadow-primary);
}
.gen-sp-btn.loading,
.gen-image-btn.loading {
pointer-events: none;
opacity: 0.75;
}
/* ==================== Modals ==================== */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.55);
z-index: 1000;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.modal-overlay.visible {
display: flex;
}
.modal-close-btn {
position: fixed;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
border: none;
background: rgba(255, 255, 255, 0.92);
color: #333;
border-radius: 50%;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1001;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: var(--transition);
}
.modal-close-btn:hover {
background: white;
transform: scale(1.1);
}
.modal-close-btn-sm {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition);
}
.modal-close-btn-sm:hover {
background: var(--bg);
color: var(--text);
}
/* ==================== Diff Dialog ==================== */
.diff-dialog {
background: var(--panel-bg);
border-radius: var(--radius-lg);
width: 80%;
max-width: 800px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.diff-header {
padding: 16px 20px;
font-weight: 600;
font-size: 15px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.diff-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.diff-section-title {
font-weight: 600;
font-size: 13px;
color: var(--text);
margin-bottom: 8px;
}
.diff-block {
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
margin-bottom: 20px;
border: 1px solid var(--border-light);
border-radius: 6px;
overflow: hidden;
}
.diff-line {
padding: 1px 10px;
white-space: pre-wrap;
word-break: break-word;
}
.diff-line.diff-added {
background: #e6ffed;
color: #22863a;
}
.diff-line.diff-removed {
background: #ffeef0;
color: #cb2431;
}
.diff-line.diff-same {
color: var(--text-secondary);
}
.diff-no-change {
color: var(--text-placeholder);
font-size: 13px;
padding: 8px 0;
}
.diff-actions {
padding: 12px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
flex-shrink: 0;
}
.cancel-btn {
padding: 8px 20px;
background: var(--panel-bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.cancel-btn:hover {
background: var(--bg);
}
.confirm-btn {
padding: 8px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.confirm-btn:hover {
background: var(--primary-dark);
}
/* ==================== History Detail Modal ==================== */
.history-detail {
background: var(--panel-bg);
border-radius: var(--radius-lg);
width: 60%;
max-width: 640px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.history-detail-header {
padding: 16px 20px;
font-weight: 600;
font-size: 15px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.history-detail-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.history-detail-footer {
padding: 12px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
flex-shrink: 0;
gap: 8px;
flex-wrap: wrap;
}
.restore-part-btn {
padding: 6px 14px;
background: var(--panel-bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.restore-part-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-light);
}
.restore-btn {
padding: 6px 20px;
background: var(--primary);
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.restore-btn:hover {
background: var(--primary-dark);
}
.detail-section {
margin-bottom: 16px;
}
.detail-section:last-child {
margin-bottom: 0;
}
.detail-section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-section-content {
font-size: 13px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
background: var(--bg);
padding: 10px 12px;
border-radius: 6px;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-light);
}
.detail-section-content.mono {
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 11px;
}
.detail-image {
max-width: 100%;
max-height: 200px;
border-radius: 6px;
border: 1px solid var(--border);
}
/* ==================== Toast ==================== */
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2000;
display: flex;
flex-direction: column-reverse;
gap: 8px;
pointer-events: none;
}
.toast {
padding: 10px 18px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: toast-in 0.3s ease;
max-width: 380px;
word-break: break-word;
pointer-events: auto;
}
.toast.toast-info {
background: #edf2fc;
color: #606266;
border: 1px solid #d9ecff;
}
.toast.toast-success {
background: #f0f9eb;
color: #529b2e;
border: 1px solid #e1f3d8;
}
.toast.toast-warning {
background: #fdf6ec;
color: #b88230;
border: 1px solid #faecd8;
}
.toast.toast-error {
background: #fef0f0;
color: #c45656;
border: 1px solid #fde2e2;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(24px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(24px); }
}
/* ==================== Add Field Dropdown ==================== */
.sp-add-dropdown {
position: fixed;
z-index: 3000;
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
padding: 4px;
min-width: 140px;
}
.sp-add-option {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 4px;
font-size: 12px;
color: var(--text);
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.sp-add-option:hover {
background: var(--primary-light);
color: var(--primary);
}
/* Inline add buttons (in dict blocks / element bodies) */
.sp-inline-add-btn {
border: 1px dashed var(--border);
background: transparent;
color: var(--text-placeholder);
border-radius: 4px;
cursor: pointer;
font-size: 11px;
padding: 2px 8px;
transition: var(--transition);
}
.sp-inline-add-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-light);
}
.sp-dict-add-row {
padding: 2px 0;
}
.sp-elem-add-row {
padding: 4px 0;
text-align: center;
}
/* Dict sub-key delete */
.sp-dict-del {
width: 16px;
height: 16px;
border: none;
background: transparent;
color: transparent;
border-radius: 3px;
cursor: pointer;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: var(--transition);
padding: 0;
}
.sp-dict-row:hover .sp-dict-del {
color: var(--text-secondary);
}
.sp-dict-del:hover {
background: var(--danger-light);
color: var(--danger);
}
/* ==================== Modal Image Zoom ==================== */
.modal-image-container {
display: flex;
align-items: center;
justify-content: center;
max-width: 92vw;
max-height: 92vh;
overflow: hidden;
}
.modal-image {
max-width: 92vw;
max-height: 92vh;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3);
transition: none;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
}
/* ==================== Loading Spinner ==================== */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(0, 0, 0, 0.12);
border-radius: 50%;
border-top-color: var(--primary);
animation: spin 0.8s linear infinite;
margin-right: 6px;
vertical-align: middle;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ==================== Scrollbar ==================== */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* ==================== Selection ==================== */
::selection {
background: rgba(99, 102, 241, 0.22);
}
/* ==================== Responsive ==================== */
@media (max-width: 900px) {
.workspace {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr auto auto auto auto;
}
.image-panel { grid-row: 1; grid-column: 1; min-height: 250px; }
.sp-panel { grid-row: 2; grid-column: 1; min-height: 200px; }
.params-bar { grid-row: 3; grid-column: 1; }
.gen-sp-btn { grid-row: 4; grid-column: 1; }
.prompt-bar { grid-row: 5; grid-column: 1; }
.gen-image-btn { grid-row: 6; grid-column: 1; }
.right-col {
max-width: none;
}
.params-bar {
flex-wrap: wrap;
gap: 8px;
}
.history-detail,
.diff-dialog {
width: 95%;
}
}
/* ==================== Floating Input Popup ==================== */
.sp-input-popup {
position: fixed;
z-index: 3000;
background: var(--panel-bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 12px;
min-width: 240px;
max-width: 320px;
animation: popup-in 0.15s ease;
}
@keyframes popup-in {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.sp-input-popup .popup-title {
font-size: 12px;
font-weight: 600;
color: var(--text);
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-light);
}
.sp-input-popup .popup-field {
margin-bottom: 8px;
}
.sp-input-popup .popup-field label {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 3px;
}
.sp-input-popup .popup-field input {
width: 100%;
height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 8px;
font-size: 12px;
color: var(--text);
background: var(--panel-bg);
outline: none;
transition: var(--transition);
}
.sp-input-popup .popup-field input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary-ring);
}
.sp-input-popup .popup-field input.input-error {
border-color: var(--danger);
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.12);
}
.sp-input-popup .popup-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--border-light);
}
.sp-input-popup .popup-btn {
height: 26px;
padding: 0 14px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: var(--panel-bg);
color: var(--text);
transition: var(--transition);
}
.sp-input-popup .popup-btn:hover {
border-color: var(--text-secondary);
color: var(--text);
}
.sp-input-popup .popup-btn-primary {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.sp-input-popup .popup-btn-primary:hover {
background: var(--primary-dark);
border-color: var(--primary-dark);
color: white;
}
</style>
</head>
<body data-generated="2026-07-29T06:50:38.723695Z">
<div class="app">
<!-- ==================== History Bar ==================== -->
<div class="history-bar">
<div class="history-title">History</div>
<div class="history-scroll" id="historyScroll">
<div class="history-empty" id="historyEmpty">No history yet</div>
</div>
<button class="tool-btn history-clear-btn" id="historyClearBtn" onclick="clearHistory()" title="Clear history" style="display:none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
<!-- ==================== Workspace (CSS Grid) ==================== -->
<div class="workspace">
<!-- Image Panel (row 1, col 1) -->
<div class="panel image-panel" id="imagePanel">
<div class="panel-toolbar">
<span class="panel-label">Image</span>
<div class="panel-actions">
<button class="tool-btn" onclick="triggerImageUpload()" title="Import image">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</button>
<button class="tool-btn" id="btnBboxShow" onclick="showAllBboxes()" title="Show all bboxes" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button class="tool-btn" id="btnBboxHide" onclick="hideAllBboxes()" title="Hide all bboxes" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
</button>
<button class="tool-btn" id="btnImageZoom" onclick="handleImageEnlarge()" title="Zoom" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<button class="tool-btn" id="btnImageDelete" onclick="handleImageDelete()" title="Delete image" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
<div class="image-area" id="imageArea">
<div class="dropzone" id="dropzone" onclick="triggerImageUpload()">
<div class="dropzone-icon">🖼️</div>
<div class="dropzone-text">Drop an image here or click to upload</div>
</div>
<div class="image-display" id="imageDisplay" style="display:none;">
<img id="displayImage" onclick="handleImageClick(event)" title="Click to select a bbox" />
<div class="bbox-container" id="bboxContainer"></div>
</div>
</div>
<input type="file" id="imageFileInput" accept="image/*" onchange="handleImageFileSelected(event)" style="display:none;">
</div>
<!-- Structured Prompt Panel (row 1, col 2) -->
<div class="panel sp-panel" id="spPanel">
<div class="panel-toolbar">
<span class="panel-label">Structured Prompt</span>
<div class="panel-actions">
<button class="tool-btn" id="btnSPRaw" onclick="toggleSPRawMode()" title="Toggle raw JSON">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</button>
<button class="tool-btn" onclick="triggerSPImport()" title="Import JSON">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</button>
<button class="tool-btn" id="btnSPUndo" onclick="handleSPUndo()" title="Undo" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
</button>
<button class="tool-btn" id="btnSPDelete" onclick="handleSPDelete()" title="Clear" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
</button>
</div>
</div>
<div class="sp-area" id="spArea">
<div class="sp-empty" id="spEmpty">
<div class="sp-empty-icon">📋</div>
<div>Structured Prompt is empty</div>
<div class="sp-empty-hint">Generate one with the button, or paste / import JSON</div>
</div>
<div class="sp-structured" id="spStructured" style="display:none;"></div>
<textarea id="spTextarea" style="display:none;" spellcheck="false"
placeholder="Paste Structured Prompt JSON here..."></textarea>
</div>
<input type="file" id="spFileInput" accept=".json,.txt" onchange="handleSPFileSelected(event)" style="display:none;">
</div>
<!-- Params Bar (row 2, col 1) -->
<div class="panel params-bar">
<div class="params-label">Params</div>
<div class="params-group">
<label>CFG</label>
<input type="number" id="paramCfgScale" value="4.0" min="0" max="20" step="0.1">
</div>
<div class="params-group">
<label>Steps</label>
<input type="number" id="paramSteps" value="25" min="1" max="100" step="1">
</div>
<div class="params-spacer"></div>
<div class="params-group">
<label>H</label>
<input type="number" id="paramHeight" value="1024" min="64" max="4096" step="32">
</div>
<div class="params-group">
<label>W</label>
<input type="number" id="paramWidth" value="1024" min="64" max="4096" step="32">
</div>
<div class="params-group">
<label>Seed</label>
<input type="number" id="paramSeed" value="42" min="-1" max="999999">
</div>
</div>
<!-- Generate SP Button (row 2, col 2) -->
<button class="action-btn gen-sp-btn" id="genSPBtn" onclick="handleGenerateSP()">
Generate SP
</button>
<!-- User Prompt Bar (row 3, col 1) -->
<div class="panel prompt-bar">
<div class="prompt-label">User Prompt</div>
<textarea id="userPromptInput" placeholder="Describe the image you want..." rows="1"></textarea>
</div>
<!-- Generate Image Button (row 3, col 2) -->
<button class="action-btn gen-image-btn" id="genImageBtn" onclick="handleGenerateImage()">
Generate Image
</button>
</div>
</div>
<!-- ==================== Image Enlarge Modal ==================== -->
<div class="modal-overlay" id="imageModal" onclick="closeImageModal()">
<div class="modal-image-container" id="modalImageContainer" onclick="event.stopPropagation()">
<img id="modalImage" class="modal-image" />
</div>
<button class="modal-close-btn" onclick="closeImageModal()"></button>
</div>
<!-- ==================== Diff Confirmation Dialog ==================== -->
<div class="modal-overlay" id="diffModal">
<div class="diff-dialog" onclick="event.stopPropagation()">
<div class="diff-header">
<span>Review changes</span>
<button class="modal-close-btn-sm" onclick="cancelGeneration()"></button>
</div>
<div class="diff-content" id="diffContent"></div>
<div class="diff-actions">
<button class="action-btn cancel-btn" onclick="cancelGeneration()">Cancel</button>
<button class="action-btn confirm-btn" onclick="confirmGeneration()">Confirm &amp; generate</button>
</div>
</div>
</div>
<!-- ==================== History Detail Modal ==================== -->
<div class="modal-overlay" id="historyModal" onclick="closeHistoryModal()">
<div class="history-detail" onclick="event.stopPropagation()">
<div class="history-detail-header">
<span>History entry</span>
<button class="modal-close-btn-sm" onclick="closeHistoryModal()"></button>
</div>
<div class="history-detail-body" id="historyDetailBody"></div>
<div class="history-detail-footer">
<button class="action-btn restore-part-btn" onclick="restoreImageFromHistory()" title="Restore image only">🖼️ Image</button>
<button class="action-btn restore-part-btn" onclick="restorePromptFromHistory()" title="Restore User Prompt &amp; SP">📝 Prompt</button>
<button class="action-btn restore-part-btn" onclick="restoreParamsFromHistory()" title="Restore params only">⚙️ Params</button>
<button class="action-btn restore-btn" onclick="restoreFromHistory()">Restore all</button>
</div>
</div>
</div>
<!-- ==================== Toast Container ==================== -->
<div class="toast-container" id="toastContainer"></div>
<!-- ==================== Add Field Dropdown ==================== -->
<div class="sp-add-dropdown" id="spAddDropdown" style="display:none;"></div>
<!-- ==================== Scripts ==================== -->
<script src="https://cdn.jsdelivr.net/npm/jsonrepair@3/lib/umd/jsonrepair.min.js"></script>
<script>
// ==================== Client-side config ====================
// This demo talks to a local FastAPI backend (see demo/app.py). All model
// inference — Structured Prompt generation via a text LLM AND image
// rendering via the QwenImage DiT — happens on the server side, so the
// browser never sees an API key or an SP system prompt.
//
// These constants are kept only so the rest of the (studio-derived)
// script continues to reference them without changes.
const DEFAULT_SYSTEM_PROMPT = "";
const DEFAULT_API_KEYS = [];
// ==================== Constants ====================
const BBOX_COLORS = [
'#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6',
'#1abc9c', '#e67e22', '#34495e', '#16a085', '#c0392b',
'#2980b9', '#27ae60', '#d35400', '#8e44ad', '#17a2b8',
'#ff6b6b', '#4ecdc4', '#45b7d1', '#f7b731', '#5f27cd'
];
const HISTORY_COLORS = ['hc-0', 'hc-1', 'hc-2', 'hc-3', 'hc-4', 'hc-5'];
// ==================== Stable JSON Stringify ====================
// Preserve object key order based on previous JSON (including nested objects in arrays).
// This avoids JS's default integer-like key reordering (e.g. "123" jumping to the front).
function _joinPath(pathKeys) {
return pathKeys.join('\0');
}
// 直接扫描 JSON 原始字符串来提取 key 顺序,避免 JSON.parse 自动把数字 key 排到前面
function _extractKeyOrdersFromJSON(jsonStr) {
if (!jsonStr || !jsonStr.trim()) return {};
try {
const keyOrders = {};
const pathStack = []; // {type:'obj'|'arr', keys:[], currentKey:null, index:0}
let i = 0;
const len = jsonStr.length;
function skipWS() { while (i < len && /\s/.test(jsonStr[i])) i++; }
function readStr() {
if (jsonStr[i] !== '"') return null;
i++; // skip "
let s = '';
while (i < len && jsonStr[i] !== '"') {
if (jsonStr[i] === '\\') { s += jsonStr[i] + jsonStr[i + 1]; i += 2; }
else { s += jsonStr[i]; i++; }
}
i++; // skip closing "
try { return JSON.parse('"' + s + '"'); } catch { return s; }
}
function curPath() {
const parts = [];
for (const f of pathStack) {
if (f.type === 'arr') parts.push(String(f.index));
else if (f.currentKey !== null) parts.push(f.currentKey);
}
return parts;
}
while (i < len) {
skipWS();
if (i >= len) break;
const ch = jsonStr[i];
if (ch === '{') {
const p = curPath();
const frame = { type: 'obj', keys: [], currentKey: null };
pathStack.push(frame);
keyOrders[_joinPath(p)] = frame.keys;
i++;
} else if (ch === '}') {
pathStack.pop();
i++;
if (pathStack.length > 0 && pathStack[pathStack.length - 1].type === 'obj') {
pathStack[pathStack.length - 1].currentKey = null;
}
} else if (ch === '[') {
pathStack.push({ type: 'arr', index: 0 });
i++;
} else if (ch === ']') {
pathStack.pop();
i++;
if (pathStack.length > 0 && pathStack[pathStack.length - 1].type === 'obj') {
pathStack[pathStack.length - 1].currentKey = null;
}
} else if (ch === ',') {
i++;
if (pathStack.length > 0) {
const top = pathStack[pathStack.length - 1];
if (top.type === 'arr') top.index++;
else top.currentKey = null;
}
} else if (ch === ':') {
i++;
} else if (ch === '"') {
const str = readStr();
skipWS();
if (i < len && jsonStr[i] === ':') {
// It's an object key
const top = pathStack[pathStack.length - 1];
if (top && top.type === 'obj') {
top.keys.push(str);
top.currentKey = str;
}
}
// else it's a string value — do nothing
} else {
// number / boolean / null
const m = jsonStr.substring(i).match(/^(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/);
if (m) i += m[0].length; else i++;
}
}
return keyOrders;
} catch (e) {
return {};
}
}
function _stableStr(val, keyOrders, pathKeys, level) {
if (val === null || val === undefined) return 'null';
if (typeof val === 'boolean') return val ? 'true' : 'false';
if (typeof val === 'number') return isFinite(val) ? String(val) : 'null';
if (typeof val === 'string') return JSON.stringify(val);
const indentStr = ' '.repeat(level);
const childIndent = ' '.repeat(level + 1);
if (Array.isArray(val)) {
if (val.length === 0) return '[]';
const items = val.map((item, i) =>
childIndent + _stableStr(item, keyOrders, [...pathKeys, String(i)], level + 1)
);
return '[\n' + items.join(',\n') + '\n' + indentStr + ']';
}
// Object
const allKeys = Object.keys(val);
if (allKeys.length === 0) return '{}';
// Get reference key order for this path
const parentPath = _joinPath(pathKeys);
const refKeys = keyOrders[parentPath] || [];
// Order: reference keys first (in reference order), then remaining keys (appended)
const orderedKeys = [];
refKeys.forEach(k => { if (allKeys.includes(k)) orderedKeys.push(k); });
allKeys.forEach(k => { if (!orderedKeys.includes(k)) orderedKeys.push(k); });
const items = orderedKeys.map(k =>
childIndent + JSON.stringify(k) + ': ' + _stableStr(val[k], keyOrders, [...pathKeys, k], level + 1)
);
return '{\n' + items.join(',\n') + '\n' + indentStr + '}';
}
function stableJSONStringify(obj, referenceStr) {
try {
const keyOrders = _extractKeyOrdersFromJSON(referenceStr);
return _stableStr(obj, keyOrders, [], 0);
} catch (e) {
return JSON.stringify(obj, null, 2);
}
}
// ==================== Global State ====================
const state = {
// Current image: { src: string (dataURL/URL), width: number, height: number } | null
currentImage: null,
// Structured Prompt (JSON string)
structuredPrompt: '',
// User Prompt
userPrompt: '',
// Generation parameters
params: {
forceT2I: false,
ditEndpoint: '',
cfgScale: 4.0,
steps: 25,
height: 1024,
width: 1024,
seed: 42,
},
// History: array of { type, inputImage, outputImage, userPrompt, structuredPrompt, params, timestamp, colorIndex }
history: [],
selectedHistoryIndex: -1,
generationCount: 0,
// SP undo stack
undoStack: [],
// UI flags
bboxVisible: false,
isGenerating: false,
pendingGeneration: null, // callback for diff confirmation
spRawMode: false, // true = raw textarea, false = structured view
activeBboxIndex: -1, // highlighted bbox element index
bboxEditIndex: -1, // bbox in edit mode (double-click to activate)
focusedBboxIndex: -1, // 被点击锁定的 bbox index(持久显示)
// BBox drag state
bboxDrag: null, // { elemIndex, handle, startX, startY, startBbox:[x1,y1,x2,y2], containerRect }
// Parsed SP data
parsedSP: null, // parsed JSON object
// Parsed elements from SP (elements + scene.elements with bbox)
parsedElements: [],
// Image modal zoom state
zoomState: { scale: 1, translateX: 0, translateY: 0 },
};
// ==================== Initialization ====================
document.addEventListener('DOMContentLoaded', init);
function init() {
setupDragDrop();
setupAutoSync();
setupKeyboardShortcuts();
}
function setupDragDrop() {
const imageArea = document.getElementById('imageArea');
const dropzone = document.getElementById('dropzone');
['dragenter', 'dragover'].forEach(evt => {
imageArea.addEventListener(evt, e => {
e.preventDefault();
e.stopPropagation();
if (dropzone) dropzone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(evt => {
imageArea.addEventListener(evt, e => {
e.preventDefault();
e.stopPropagation();
if (dropzone) dropzone.classList.remove('dragover');
});
});
imageArea.addEventListener('drop', e => {
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].type.startsWith('image/')) {
loadImageFile(files[0]);
}
});
// Also support paste
document.addEventListener('paste', e => {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) loadImageFile(file);
break;
}
}
});
}
function setupAutoSync() {
// Sync userPrompt from input
document.getElementById('userPromptInput').addEventListener('input', function () {
state.userPrompt = this.value;
});
// Sync SP from textarea
document.getElementById('spTextarea').addEventListener('input', function () {
state.structuredPrompt = this.value;
updateSPButtons();
parseSPAndUpdateBboxes();
});
// Sync params on change
['paramCfgScale', 'paramSteps', 'paramHeight', 'paramWidth', 'paramSeed'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', syncParamsFromUI);
});
}
function setupKeyboardShortcuts() {
// Ctrl+Z when focused on SP textarea => undo
document.getElementById('spTextarea').addEventListener('keydown', function (e) {
if (e.ctrlKey && e.key === 'z') {
e.preventDefault();
handleSPUndo();
}
});
// Ctrl+Enter in user prompt => generate SP
document.getElementById('userPromptInput').addEventListener('keydown', function (e) {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
handleGenerateSP();
}
});
}
// ==================== Params Sync ====================
// Small helpers so removing a UI element (e.g. Force-T2I, URL) doesn't
// crash the whole flow with "Cannot read properties of null".
function _byId(id) { return document.getElementById(id); }
function _valOr(id, dflt) { const el = _byId(id); return el ? el.value : dflt; }
function _checkedOr(id, dflt) { const el = _byId(id); return el ? el.checked : dflt; }
function syncParamsFromUI() {
state.params.forceT2I = _checkedOr('paramForceT2I', false);
state.params.ditEndpoint = (_valOr('paramDitEndpoint', '') || '').trim();
state.params.cfgScale = parseFloat(_valOr('paramCfgScale', 4.0)) || 4.0;
state.params.steps = parseInt(_valOr('paramSteps', 25)) || 25;
state.params.height = parseInt(_valOr('paramHeight', 1024)) || 1024;
state.params.width = parseInt(_valOr('paramWidth', 1024)) || 1024;
state.params.seed = parseInt(_valOr('paramSeed', 42)) || 42;
}
function _setIfExists(id, val, prop) {
const el = _byId(id);
if (el) el[prop] = val;
}
function syncParamsToUI() {
_setIfExists('paramForceT2I', state.params.forceT2I || false, 'checked');
_setIfExists('paramDitEndpoint', state.params.ditEndpoint || '', 'value');
document.getElementById('paramCfgScale').value = state.params.cfgScale !== undefined ? state.params.cfgScale : 4.0;
document.getElementById('paramSteps').value = state.params.steps;
document.getElementById('paramHeight').value = state.params.height;
document.getElementById('paramWidth').value = state.params.width;
document.getElementById('paramSeed').value = state.params.seed;
}
// ==================== Image Panel ====================
function triggerImageUpload() {
document.getElementById('imageFileInput').click();
}
function handleImageFileSelected(event) {
const file = event.target.files[0];
if (file) loadImageFile(file);
event.target.value = '';
}
function loadImageFile(file) {
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = function () {
state.currentImage = {
src: e.target.result,
width: img.naturalWidth,
height: img.naturalHeight,
};
updateImagePanel();
autoUpdateHW();
showToast('Image loaded', 'success');
};
img.onerror = function () {
showToast('Failed to load image', 'error');
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function loadImageFromSrc(src) {
const img = new Image();
img.onload = function () {
state.currentImage = {
src: src,
width: img.naturalWidth,
height: img.naturalHeight,
};
updateImagePanel();
};
img.src = src;
}
function autoUpdateHW() {
if (!state.currentImage) return;
// Round to nearest 32
state.params.height = Math.round(state.currentImage.height / 32) * 32;
state.params.width = Math.round(state.currentImage.width / 32) * 32;
syncParamsToUI();
}
function handleImageDelete() {
state.currentImage = null;
state.bboxVisible = false;
state.activeBboxIndex = -1;
updateImagePanel();
showToast('Image removed', 'info');
}
function handleImageEnlarge() {
if (!state.currentImage) return;
const modal = document.getElementById('imageModal');
const img = document.getElementById('modalImage');
img.src = state.currentImage.src;
// Reset zoom
state.zoomState = { scale: 1, translateX: 0, translateY: 0 };
applyModalZoom();
modal.classList.add('visible');
// Setup zoom listeners
const container = document.getElementById('modalImageContainer');
container.addEventListener('wheel', onModalWheel, { passive: false });
container.addEventListener('mousedown', onModalPanStart);
}
function closeImageModal() {
const modal = document.getElementById('imageModal');
modal.classList.remove('visible');
const container = document.getElementById('modalImageContainer');
container.removeEventListener('wheel', onModalWheel);
container.removeEventListener('mousedown', onModalPanStart);
state.zoomState = { scale: 1, translateX: 0, translateY: 0 };
}
function onModalWheel(e) {
e.preventDefault();
const z = state.zoomState;
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const newScale = Math.max(0.5, Math.min(20, z.scale * factor));
// Zoom towards mouse position
const container = document.getElementById('modalImageContainer');
const rect = container.getBoundingClientRect();
const mx = e.clientX - rect.left - rect.width / 2;
const my = e.clientY - rect.top - rect.height / 2;
const sf = newScale / z.scale;
z.translateX = mx - sf * (mx - z.translateX);
z.translateY = my - sf * (my - z.translateY);
z.scale = newScale;
applyModalZoom();
}
function onModalPanStart(e) {
if (e.button !== 0) return;
if (state.zoomState.scale <= 1.01) return;
e.preventDefault();
const z = state.zoomState;
const startX = e.clientX, startY = e.clientY;
const startTX = z.translateX, startTY = z.translateY;
const onMove = (ev) => {
z.translateX = startTX + (ev.clientX - startX);
z.translateY = startTY + (ev.clientY - startY);
applyModalZoom();
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
function applyModalZoom() {
const img = document.getElementById('modalImage');
const z = state.zoomState;
img.style.transform = `translate(${z.translateX}px, ${z.translateY}px) scale(${z.scale})`;
img.style.cursor = z.scale > 1.01 ? 'grab' : 'default';
}
function updateImagePanel() {
const dropzone = document.getElementById('dropzone');
const display = document.getElementById('imageDisplay');
const btnZoom = document.getElementById('btnImageZoom');
const btnDelete = document.getElementById('btnImageDelete');
const btnBboxShow = document.getElementById('btnBboxShow');
const btnBboxHide = document.getElementById('btnBboxHide');
if (state.currentImage) {
dropzone.style.display = 'none';
display.style.display = 'flex';
document.getElementById('displayImage').src = state.currentImage.src;
btnZoom.style.display = '';
btnDelete.style.display = '';
updateBboxButtons();
if (state.bboxVisible) {
requestAnimationFrame(() => renderBboxes());
}
} else {
dropzone.style.display = '';
display.style.display = 'none';
btnZoom.style.display = 'none';
btnDelete.style.display = 'none';
btnBboxShow.style.display = 'none';
btnBboxHide.style.display = 'none';
clearBboxes();
}
}
function updateBboxButtons() {
const hasElems = state.parsedElements.length > 0;
const btnBboxShow = document.getElementById('btnBboxShow');
const btnBboxHide = document.getElementById('btnBboxHide');
if (!state.currentImage || !hasElems) {
btnBboxShow.style.display = 'none';
btnBboxHide.style.display = 'none';
return;
}
if (state.bboxVisible) {
btnBboxShow.style.display = 'none';
btnBboxHide.style.display = '';
} else {
btnBboxShow.style.display = '';
btnBboxHide.style.display = 'none';
}
}
// ==================== BBox Visualization ====================
function showAllBboxes() {
if (state.parsedElements.length === 0) {
parseSPAndUpdateBboxes();
if (state.parsedElements.length === 0) return;
}
state.focusedBboxIndex = -1;
state.bboxVisible = true;
state.activeBboxIndex = -1;
state.bboxEditIndex = -1;
renderBboxes(-1); // -1 means render all
updateBboxButtons();
document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight'));
}
function hideAllBboxes() {
state.focusedBboxIndex = -1;
state.bboxVisible = false;
state.activeBboxIndex = -1;
state.bboxEditIndex = -1;
clearBboxes();
updateBboxButtons();
document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight'));
}
function handleImageClick(event) {
// 检测点击位置是否有 element 的 bbox
if (state.parsedElements.length > 0 && state.currentImage) {
const imgEl = document.getElementById('displayImage');
const imgRect = imgEl.getBoundingClientRect();
const clickX = ((event.clientX - imgRect.left) / imgRect.width) * 1000;
const clickY = ((event.clientY - imgRect.top) / imgRect.height) * 1000;
// 找所有包含点击坐标的 bbox
const overlapping = [];
state.parsedElements.forEach((elem, i) => {
if (!elem.bbox) return;
const [x1, y1, x2, y2] = elem.bbox;
if (clickX >= x1 && clickX <= x2 && clickY >= y1 && clickY <= y2) {
overlapping.push(i);
}
});
if (overlapping.length > 0) {
// 在重叠列表中循环切换
let nextIndex;
if (state.focusedBboxIndex >= 0 && overlapping.includes(state.focusedBboxIndex)) {
const currentPos = overlapping.indexOf(state.focusedBboxIndex);
nextIndex = overlapping[(currentPos + 1) % overlapping.length];
} else {
nextIndex = overlapping[0];
}
// 聚焦该 element 的 bbox
focusBbox(nextIndex);
// 滚动到对应的 element 卡片
if (state.parsedElements[nextIndex]) {
const card = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[nextIndex].id}"]`);
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
return;
}
}
// 点击空白区域 => 清除锁定的 bbox
state.focusedBboxIndex = -1;
state.activeBboxIndex = -1;
state.bboxEditIndex = -1;
if (state.bboxVisible) {
clearBboxes();
state.bboxVisible = false;
updateBboxButtons();
}
document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight'));
}
// filterIndex: 如果 >= 0,只渲染该索引的 bbox;否则渲染全部
function renderBboxes(filterIndex) {
clearBboxes();
if (!state.currentImage || state.parsedElements.length === 0) return;
const imgEl = document.getElementById('displayImage');
const container = document.getElementById('bboxContainer');
if (!imgEl || !imgEl.offsetWidth) return;
const imgRect = imgEl.getBoundingClientRect();
const parentRect = imgEl.parentElement.getBoundingClientRect();
container.style.left = (imgRect.left - parentRect.left) + 'px';
container.style.top = (imgRect.top - parentRect.top) + 'px';
container.style.width = imgRect.width + 'px';
container.style.height = imgRect.height + 'px';
container.style.display = 'block';
state.parsedElements.forEach((elem, i) => {
if (!elem.bbox) return;
// 如果指定了 filterIndex,只渲染该元素
if (filterIndex !== undefined && filterIndex >= 0 && i !== filterIndex) return;
const [x1, y1, x2, y2] = elem.bbox;
const color = getColorForElemId(elem.id);
const isActive = (i === state.bboxEditIndex);
const box = document.createElement('div');
box.className = 'bbox-box' + (isActive ? ' active' : '');
box.dataset.elemIndex = i;
box.style.borderColor = color;
box.style.left = (x1 / 1000 * 100) + '%';
box.style.top = (y1 / 1000 * 100) + '%';
box.style.width = ((x2 - x1) / 1000 * 100) + '%';
box.style.height = ((y2 - y1) / 1000 * 100) + '%';
// Semi-transparent background
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
box.style.background = `rgba(${r},${g},${b},0.12)`;
// Label
const label = document.createElement('div');
label.className = 'bbox-label';
label.style.background = color;
label.textContent = `${elem.id}: ${elem.caption || ''}`.substring(0, 30);
box.appendChild(label);
// Drag to move / click to highlight (mousedown on box body)
box.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('bbox-handle')) return;
e.preventDefault();
e.stopPropagation();
// 仅在编辑模式下允许拖拽移动,否则仅处理点击
if (state.bboxEditIndex !== i) {
handleBboxClick(i, e);
return;
}
state.bboxClickStart = { x: e.clientX, y: e.clientY, index: i };
initBboxDrag(e, i, 'move');
});
// Resize handles (8 directions) — only visible when active
const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'w', 'e'];
handles.forEach(h => {
const handle = document.createElement('div');
handle.className = `bbox-handle h-${h}`;
handle.style.borderColor = color;
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
highlightBbox(i);
initBboxDrag(e, i, h);
});
box.appendChild(handle);
});
container.appendChild(box);
});
}
// 点击 bbox 时处理重叠切换
function handleBboxClick(clickedIndex, e) {
const container = document.getElementById('bboxContainer');
const containerRect = container.getBoundingClientRect();
const clickX = ((e.clientX - containerRect.left) / containerRect.width) * 1000;
const clickY = ((e.clientY - containerRect.top) / containerRect.height) * 1000;
// 找所有包含点击坐标的 bbox
const overlapping = [];
state.parsedElements.forEach((elem, i) => {
if (!elem.bbox) return;
const [x1, y1, x2, y2] = elem.bbox;
if (clickX >= x1 && clickX <= x2 && clickY >= y1 && clickY <= y2) {
overlapping.push(i);
}
});
if (overlapping.length <= 1) {
// 单个 bbox:聚焦并显示该 bbox(不进入编辑模式)
focusBbox(clickedIndex);
return;
}
// 在重叠列表中循环切换
const currentIdx = state.focusedBboxIndex >= 0 ? state.focusedBboxIndex : state.activeBboxIndex;
const currentPos = overlapping.indexOf(currentIdx);
const nextPos = (currentPos + 1) % overlapping.length;
const nextIdx = overlapping[nextPos];
// 重叠切换时必须用 focus,确保左侧图像 bbox 与右侧选中项一致
focusBbox(nextIdx);
}
function clearBboxes() {
const container = document.getElementById('bboxContainer');
container.innerHTML = '';
container.style.display = 'none';
}
function parseSPAndUpdateBboxes() {
state.parsedElements = [];
state.parsedSP = null;
if (!state.structuredPrompt.trim()) {
if (state.bboxVisible) clearBboxes();
updateBboxButtons();
return;
}
try {
const data = JSON.parse(state.structuredPrompt);
state.parsedSP = data;
state.parsedElements = extractElements(data);
} catch (e) {
// Not valid JSON yet
}
if (state.bboxVisible && state.currentImage) {
renderBboxes();
}
updateBboxButtons();
}
// 基于元素 ID 的稳定颜色映射(不因数组顺序变化而改变)
function getColorForElemId(id) {
const numId = parseInt(id) || 0;
return BBOX_COLORS[numId % BBOX_COLORS.length];
}
function parseBbox(positionStr) {
if (!positionStr) return null;
const match = positionStr.match(/<bbox>([^<]+)<\/bbox>/) || positionStr.match(/([\d\s]+)/);
if (!match) return null;
const coords = match[1].trim().split(/\s+/).map(Number);
if (coords.length !== 4) return null;
return coords;
}
function extractElements(data) {
const elements = [];
if (data.elements && Array.isArray(data.elements)) {
data.elements.forEach(elem => {
if (elem.position) {
const bbox = parseBbox(elem.position);
if (bbox) {
elements.push({ id: elem.id, caption: elem.caption || '', bbox, source: 'elements' });
}
}
});
}
if (data.scene && data.scene.elements && Array.isArray(data.scene.elements)) {
data.scene.elements.forEach(elem => {
if (elem.position) {
const bbox = parseBbox(elem.position);
if (bbox) {
elements.push({ id: elem.id, caption: elem.caption || '', bbox, source: 'scene' });
}
}
});
}
return elements;
}
// ==================== SP Panel ====================
function triggerSPImport() {
document.getElementById('spFileInput').click();
}
function handleSPFileSelected(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
pushSPUndo();
state.structuredPrompt = e.target.result;
parseSPAndUpdateBboxes();
updateSPPanel();
showToast('Structured Prompt imported', 'success');
};
reader.readAsText(file);
event.target.value = '';
}
function handleSPDelete() {
if (!state.structuredPrompt.trim()) return;
pushSPUndo();
state.structuredPrompt = '';
state.parsedSP = null;
updateSPPanel();
clearBboxes();
state.parsedElements = [];
state.bboxVisible = false;
showToast('Structured Prompt cleared', 'info');
}
function handleSPUndo() {
if (state.undoStack.length === 0) {
showToast('Nothing to undo', 'info');
return;
}
state.structuredPrompt = state.undoStack.pop();
parseSPAndUpdateBboxes();
updateSPPanel();
showToast('Undone', 'success');
}
function pushSPUndo() {
state.undoStack.push(state.structuredPrompt);
if (state.undoStack.length > 50) state.undoStack.shift();
}
function toggleSPRawMode() {
state.spRawMode = !state.spRawMode;
const btn = document.getElementById('btnSPRaw');
if (state.spRawMode) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
// Sync textarea back to state when switching to structured view
const ta = document.getElementById('spTextarea');
if (ta.style.display !== 'none') {
state.structuredPrompt = ta.value;
parseSPAndUpdateBboxes();
}
}
updateSPPanel();
}
function updateSPPanel() {
const spEmpty = document.getElementById('spEmpty');
const spTextarea = document.getElementById('spTextarea');
const spStructured = document.getElementById('spStructured');
const btnUndo = document.getElementById('btnSPUndo');
const btnDelete = document.getElementById('btnSPDelete');
if (state.structuredPrompt.trim()) {
spEmpty.style.display = 'none';
if (state.spRawMode) {
spTextarea.style.display = 'block';
spStructured.style.display = 'none';
spTextarea.value = state.structuredPrompt;
} else {
spTextarea.style.display = 'none';
spStructured.style.display = 'block';
renderSPStructured();
}
btnDelete.style.display = '';
} else {
// SP 为空时显示 textarea 供直接输入
spEmpty.style.display = 'none';
spTextarea.style.display = 'block';
spStructured.style.display = 'none';
spTextarea.value = '';
btnDelete.style.display = 'none';
}
btnUndo.style.display = state.undoStack.length > 0 ? '' : 'none';
}
function updateSPButtons() {
const btnUndo = document.getElementById('btnSPUndo');
const btnDelete = document.getElementById('btnSPDelete');
btnUndo.style.display = state.undoStack.length > 0 ? '' : 'none';
btnDelete.style.display = state.structuredPrompt.trim() ? '' : 'none';
}
// Set SP content programmatically (e.g., from LLM result)
function setSPContent(text) {
pushSPUndo();
state.structuredPrompt = text;
parseSPAndUpdateBboxes();
updateSPPanel();
}
// ==================== SP Structured View ====================
// Key fields displayed in the first section
const SP_KEY_FIELDS = ['intent', 'style', 'atmosphere', 'lighting', 'photography', 'relationships'];
function formatDisplayValue(value) {
return value === '' ? '""' : value;
}
// 根据 reference JSON 字符串中的 key 出现顺序,返回有序 key 列表
let _renderKeyOrders = {};
function getStableKeys(obj, pathKeys) {
const allKeys = Object.keys(obj);
const pathStr = _joinPath(pathKeys);
const refKeys = _renderKeyOrders[pathStr] || [];
const ordered = [];
refKeys.forEach(k => { if (allKeys.includes(k)) ordered.push(k); });
allKeys.forEach(k => { if (!ordered.includes(k)) ordered.push(k); });
return ordered;
}
function renderSPStructured() {
const container = document.getElementById('spStructured');
container.innerHTML = '';
// 先确保 parsedElements 已就绪
if (state.parsedElements.length === 0 && state.structuredPrompt.trim()) {
parseSPAndUpdateBboxes();
}
// 从原始 JSON 字符串提取 key 顺序(避免 Object.keys 自动排序数字 key)
_renderKeyOrders = _extractKeyOrdersFromJSON(state.structuredPrompt);
let data;
try {
data = JSON.parse(state.structuredPrompt);
state.parsedSP = data;
} catch (e) {
container.innerHTML = '<div style="padding:12px;color:var(--danger);font-size:12px;">JSON parse failed — switch to raw mode to edit</div>';
return;
}
// ===== Section 1: Global Properties =====
const keySection = createSection('Global', '🔑', undefined, 'global');
const keyBody = keySection.querySelector('.sp-section-body');
SP_KEY_FIELDS.forEach(key => {
if (data[key] === undefined) return;
const val = data[key];
if (typeof val === 'object' && !Array.isArray(val)) {
keyBody.appendChild(createDictField(key, val, [key]));
} else if (key === 'relationships' && Array.isArray(val)) {
keyBody.appendChild(createRelationshipsField(val));
} else {
keyBody.appendChild(createStringField(key, String(val), [key]));
}
});
// Other top-level keys not in SP_KEY_FIELDS, elements, scene
getStableKeys(data, []).forEach(key => {
if (SP_KEY_FIELDS.includes(key) || key === 'elements' || key === 'scene') return;
const val = data[key];
if (typeof val === 'object' && !Array.isArray(val)) {
keyBody.appendChild(createDictField(key, val, [key]));
} else {
const displayVal = typeof val === 'string' ? val : JSON.stringify(val);
keyBody.appendChild(createStringField(key, displayVal, [key]));
}
});
container.appendChild(keySection);
// ===== Section 2: Elements =====
const elemArr = data.elements || [];
const elemSection = createSection('Elements', '🎯', elemArr.length, 'elements');
const elemBody = elemSection.querySelector('.sp-section-body');
elemArr.forEach((elem, idx) => {
elemBody.appendChild(createElementCard(elem, idx, ['elements', idx]));
});
container.appendChild(elemSection);
// ===== Section 3: Scene =====
const scene = data.scene || {};
const sceneElems = scene.elements || [];
const sceneSection = createSection('Scene', '🏞️', sceneElems.length, 'scene');
const sceneBody = sceneSection.querySelector('.sp-section-body');
// Scene setting
if (scene.setting) {
sceneBody.appendChild(createStringField('setting', scene.setting, ['scene', 'setting']));
}
// Scene other keys
getStableKeys(scene, ['scene']).forEach(key => {
if (key === 'setting' || key === 'elements') return;
const val = scene[key];
if (typeof val === 'object' && !Array.isArray(val)) {
sceneBody.appendChild(createDictField(key, val, ['scene', key]));
} else {
sceneBody.appendChild(createStringField(key, String(val), ['scene', key]));
}
});
// Scene elements
sceneElems.forEach((elem, idx) => {
sceneBody.appendChild(createElementCard(elem, idx, ['scene', 'elements', idx], true));
});
container.appendChild(sceneSection);
}
function createSection(title, icon, count, sectionType) {
const section = document.createElement('div');
section.className = 'sp-section';
const header = document.createElement('div');
header.className = 'sp-section-header';
header.onclick = function(e) {
if (e.target.closest('.sp-section-add-btn') || e.target.closest('.sp-section-clear-btn')) return;
section.classList.toggle('collapsed');
};
const titleSpan = document.createElement('span');
titleSpan.className = 'sp-section-title';
titleSpan.innerHTML = `${icon} ${title}${count !== undefined ? ` <span class='sp-section-badge'>${count}</span>` : ''}`;
const actionsDiv = document.createElement('div');
actionsDiv.className = 'sp-section-actions';
// 统一的 "+" Add按钮
const addBtn = document.createElement('button');
addBtn.className = 'sp-section-add-btn';
addBtn.textContent = '+';
addBtn.title = 'Add';
addBtn.onclick = function(e) {
e.stopPropagation();
showSectionAddDropdown(addBtn, sectionType);
};
actionsDiv.appendChild(addBtn);
// 🗑️ clear all button
const clearBtn = document.createElement('button');
clearBtn.className = 'sp-section-clear-btn';
clearBtn.innerHTML = '🗑';
clearBtn.title = 'Clear section';
clearBtn.onclick = function(e) {
e.stopPropagation();
clearSectionFields(sectionType);
};
actionsDiv.appendChild(clearBtn);
const arrow = document.createElement('span');
arrow.className = 'sp-section-arrow';
arrow.textContent = '▾';
actionsDiv.appendChild(arrow);
header.appendChild(titleSpan);
header.appendChild(actionsDiv);
section.appendChild(header);
const body = document.createElement('div');
body.className = 'sp-section-body';
section.appendChild(body);
return section;
}
function createStringField(key, value, path) {
const field = document.createElement('div');
field.className = 'sp-field';
const keySpan = document.createElement('span');
keySpan.className = 'sp-field-key';
keySpan.textContent = key;
keySpan.title = 'Double-click to edit key';
keySpan.dataset.path = JSON.stringify(path.slice(0, -1));
keySpan.dataset.oldKey = key;
keySpan.ondblclick = function() { startKeyEdit(this); };
field.appendChild(keySpan);
const valSpan = document.createElement('span');
valSpan.className = 'sp-field-value';
const renderedVal = formatDisplayValue(value);
valSpan.textContent = renderedVal;
if (value === '') {
valSpan.dataset.emptyPlaceholder = '1';
} else {
delete valSpan.dataset.emptyPlaceholder;
}
valSpan.title = 'Double-click to edit';
valSpan.dataset.path = JSON.stringify(path);
valSpan.ondblclick = function() { startFieldEdit(this); };
field.appendChild(valSpan);
const actions = document.createElement('div');
actions.className = 'sp-field-actions';
const delBtn = document.createElement('button');
delBtn.className = 'sp-field-btn';
delBtn.textContent = '✕';
delBtn.title = 'Remove';
delBtn.onclick = function() { deleteSPField(path); };
actions.appendChild(delBtn);
field.appendChild(actions);
return field;
}
function createDictField(key, dictObj, basePath) {
const field = document.createElement('div');
field.className = 'sp-field sp-field-dict';
const keySpan = document.createElement('span');
keySpan.className = 'sp-field-key';
keySpan.textContent = key;
keySpan.title = 'Double-click to edit key';
keySpan.dataset.path = JSON.stringify(basePath.slice(0, -1).length ? basePath.slice(0, -1) : []);
keySpan.dataset.oldKey = key;
keySpan.ondblclick = function() { startKeyEdit(this); };
field.appendChild(keySpan);
const block = document.createElement('div');
block.className = 'sp-dict-block';
getStableKeys(dictObj, basePath).forEach(subKey => {
const subVal = dictObj[subKey];
const row = document.createElement('div');
row.className = 'sp-dict-row';
const subPath = [...basePath, subKey];
const dKey = document.createElement('span');
dKey.className = 'sp-dict-key';
dKey.textContent = subKey;
dKey.title = 'Double-click to edit key';
dKey.dataset.path = JSON.stringify(basePath);
dKey.dataset.oldKey = subKey;
dKey.ondblclick = function() { startKeyEdit(this); };
row.appendChild(dKey);
const dVal = document.createElement('span');
dVal.className = 'sp-dict-value';
if (typeof subVal === 'string') {
dVal.textContent = formatDisplayValue(subVal);
if (subVal === '') {
dVal.dataset.emptyPlaceholder = '1';
} else {
delete dVal.dataset.emptyPlaceholder;
}
} else {
dVal.textContent = JSON.stringify(subVal);
delete dVal.dataset.emptyPlaceholder;
}
dVal.title = 'Double-click to edit';
dVal.dataset.path = JSON.stringify(subPath);
dVal.ondblclick = function() { startFieldEdit(this); };
row.appendChild(dVal);
// Delete sub-key button
const delSub = document.createElement('button');
delSub.className = 'sp-dict-del';
delSub.textContent = '✕';
delSub.title = 'Remove';
delSub.onclick = function(e) { e.stopPropagation(); deleteSPField(subPath); };
row.appendChild(delSub);
block.appendChild(row);
});
// Inline add row for dict — 字典内只Add key-value(字符串)
const addRow = document.createElement('div');
addRow.className = 'sp-dict-add-row';
const addBtnInline = document.createElement('button');
addBtnInline.className = 'sp-inline-add-btn';
addBtnInline.textContent = '+';
addBtnInline.title = 'Add key-value';
addBtnInline.onclick = function(e) {
e.stopPropagation();
promptAddDictEntry(basePath, addBtnInline);
};
addRow.appendChild(addBtnInline);
block.appendChild(addRow);
field.appendChild(block);
const actions = document.createElement('div');
actions.className = 'sp-field-actions';
const delBtn = document.createElement('button');
delBtn.className = 'sp-field-btn';
delBtn.textContent = '✕';
delBtn.title = 'Remove';
delBtn.onclick = function() { deleteSPField(basePath); };
actions.appendChild(delBtn);
field.appendChild(actions);
return field;
}
function createRelationshipsField(relArr) {
const field = document.createElement('div');
field.className = 'sp-field';
const keySpan = document.createElement('span');
keySpan.className = 'sp-field-key';
keySpan.textContent = 'relationships';
field.appendChild(keySpan);
const list = document.createElement('ul');
list.className = 'sp-rel-list';
relArr.forEach((rel, i) => {
const li = document.createElement('li');
li.className = 'sp-rel-item';
const bullet = document.createElement('span');
bullet.className = 'sp-rel-bullet';
bullet.textContent = '•';
li.appendChild(bullet);
const text = document.createElement('span');
text.className = 'sp-rel-text';
text.textContent = rel;
text.title = 'Double-click to edit';
text.dataset.path = JSON.stringify(['relationships', i]);
text.ondblclick = function() { startFieldEdit(this); };
li.appendChild(text);
list.appendChild(li);
});
field.appendChild(list);
const actions = document.createElement('div');
actions.className = 'sp-field-actions';
const delBtn = document.createElement('button');
delBtn.className = 'sp-field-btn';
delBtn.textContent = '✕';
delBtn.title = 'Remove';
delBtn.onclick = function() { deleteSPField(['relationships']); };
actions.appendChild(delBtn);
field.appendChild(actions);
return field;
}
function createElementCard(elem, idx, basePath, isScene) {
// 使用 ID 稳定颜色映射
const color = getColorForElemId(elem.id);
// 动态查找 globalIdx,确保即使 parsedElements 延迟填充也能正确工作
const elemId = elem.id;
function _getGlobalIdx() { return findParsedElementIndex(elemId, isScene); }
const card = document.createElement('div');
card.className = 'sp-element';
card.dataset.elemId = elem.id;
// Header
const header = document.createElement('div');
header.className = 'sp-element-header';
const colorDot = document.createElement('span');
colorDot.className = 'sp-element-color';
colorDot.style.background = color;
header.appendChild(colorDot);
const idSpan = document.createElement('span');
idSpan.className = 'sp-element-id';
idSpan.textContent = 'ID:' + elem.id;
header.appendChild(idSpan);
const captionSpan = document.createElement('span');
captionSpan.className = 'sp-element-caption';
captionSpan.textContent = elem.caption || '';
header.appendChild(captionSpan);
const elemActions = document.createElement('div');
elemActions.className = 'sp-element-actions';
const elemAddBtn = document.createElement('button');
elemAddBtn.className = 'sp-field-btn sp-elem-add-btn';
elemAddBtn.textContent = '+';
elemAddBtn.title = 'Add field';
elemAddBtn.addEventListener('click', function(e) {
e.stopPropagation();
e.preventDefault();
showAddDropdown(elemAddBtn, basePath);
});
elemActions.appendChild(elemAddBtn);
const elemDelBtn = document.createElement('button');
elemDelBtn.className = 'sp-field-btn';
elemDelBtn.textContent = '✕';
elemDelBtn.title = 'Remove element';
elemDelBtn.addEventListener('click', function(e) {
e.stopPropagation();
e.preventDefault();
deleteSPField(basePath);
});
elemActions.appendChild(elemDelBtn);
header.appendChild(elemActions);
header.addEventListener('click', function(e) {
if (e.target.closest('.sp-field-btn')) return;
card.classList.toggle('collapsed');
// 点击 element → 立即显示对应 bbox
focusBbox(_getGlobalIdx());
});
// Mouse enter => 只显示该 element 的 bbox(hover 预览),保存之前状态
card.addEventListener('mouseenter', () => {
const gIdx = _getGlobalIdx();
if (!state.currentImage || gIdx < 0) return;
card._savedBboxState = {
bboxVisible: state.bboxVisible,
activeBboxIndex: state.activeBboxIndex,
focusedBboxIndex: state.focusedBboxIndex,
bboxEditIndex: state.bboxEditIndex,
};
state.activeBboxIndex = gIdx;
state.bboxEditIndex = -1;
state.bboxVisible = true;
renderBboxes(gIdx);
updateBboxButtons();
document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight'));
card.classList.add('highlight');
});
card.addEventListener('mouseleave', () => {
if (!state.currentImage) return;
const saved = card._savedBboxState;
card._savedBboxState = null;
if (!saved) return;
// 如果 hover 期间用户点击锁定了新的 bbox,保持新状态
if (state.focusedBboxIndex >= 0 && state.focusedBboxIndex !== saved.focusedBboxIndex) {
state.activeBboxIndex = state.focusedBboxIndex;
state.bboxEditIndex = state.bboxEditIndex; // 保持编辑状态
renderBboxes(state.focusedBboxIndex);
updateBboxButtons();
document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight'));
if (state.parsedElements[state.focusedBboxIndex]) {
const fc = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[state.focusedBboxIndex].id}"]`);
if (fc) fc.classList.add('highlight');
}
return;
}
// 恢复之前的状态
state.bboxVisible = saved.bboxVisible;
state.activeBboxIndex = saved.activeBboxIndex;
state.focusedBboxIndex = saved.focusedBboxIndex;
state.bboxEditIndex = saved.bboxEditIndex;
if (saved.bboxVisible) {
if (saved.focusedBboxIndex >= 0) {
renderBboxes(saved.focusedBboxIndex);
} else {
renderBboxes(-1);
}
} else {
clearBboxes();
}
updateBboxButtons();
document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight'));
if (saved.focusedBboxIndex >= 0 && state.parsedElements[saved.focusedBboxIndex]) {
const fc = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[saved.focusedBboxIndex].id}"]`);
if (fc) fc.classList.add('highlight');
}
});
card.appendChild(header);
// Body
const body = document.createElement('div');
body.className = 'sp-element-body';
const skipKeys = ['id'];
getStableKeys(elem, basePath).forEach(key => {
if (skipKeys.includes(key)) return;
const val = elem[key];
const fieldPath = [...basePath, key];
if (key === 'position') {
const posField = document.createElement('div');
posField.className = 'sp-field';
const posKeySpan = document.createElement('span');
posKeySpan.className = 'sp-field-key';
posKeySpan.textContent = 'position';
posField.appendChild(posKeySpan);
const posBadge = document.createElement('span');
posBadge.className = 'sp-pos-badge';
posBadge.title = 'Click to locate';
posBadge.innerHTML = '<span class="pos-icon">📍</span> ' + escapeHtml(extractBboxText(val));
posBadge.onclick = function(ev) { ev.stopPropagation(); focusBbox(_getGlobalIdx()); };
posField.appendChild(posBadge);
// 编辑按钮 — 进入 bbox 编辑模式(可拖拽/缩放)
const editBtn = document.createElement('button');
editBtn.className = 'sp-field-btn sp-bbox-edit-btn';
editBtn.textContent = '✏️';
editBtn.title = 'Edit bbox position';
editBtn.onclick = function(ev) {
ev.stopPropagation();
const gIdx = _getGlobalIdx();
if (gIdx < 0) {
showToast("BBox data isn't ready yet, please retry", 'warning');
return;
}
activateBboxEdit(gIdx);
};
posField.appendChild(editBtn);
body.appendChild(posField);
} else if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
body.appendChild(createDictField(key, val, fieldPath));
} else {
body.appendChild(createStringField(key, typeof val === 'string' ? val : JSON.stringify(val), fieldPath));
}
});
card.appendChild(body);
return card;
}
function extractBboxText(posStr) {
if (!posStr) return '';
const match = posStr.match(/<bbox>([^<]+)<\/bbox>/);
return match ? match[1].trim() : posStr;
}
function findParsedElementIndex(elemId, isScene) {
// Find element index in state.parsedElements by id
for (let i = 0; i < state.parsedElements.length; i++) {
if (state.parsedElements[i].id === elemId) return i;
}
return -1;
}
function highlightBbox(globalIdx) {
state.activeBboxIndex = globalIdx;
// Update bbox visual highlighting (使用 data-elemIndex 匹配)
const container = document.getElementById('bboxContainer');
container.querySelectorAll('.bbox-box').forEach(box => {
const idx = parseInt(box.dataset.elemIndex);
box.classList.remove('active', 'focused');
if (idx === globalIdx) {
if (state.bboxEditIndex === globalIdx) {
box.classList.add('active');
} else {
box.classList.add('focused');
}
}
});
// Also highlight SP element card
document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight'));
if (globalIdx >= 0 && state.parsedElements[globalIdx]) {
const card = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[globalIdx].id}"]`);
if (card) card.classList.add('highlight');
}
}
function focusBbox(globalIdx) {
if (globalIdx < 0 || !state.currentImage) return;
// 点击 element => 锁定只显示该 element 的 bbox(仅查看,不带编辑手柄)
state.focusedBboxIndex = globalIdx;
state.activeBboxIndex = globalIdx;
state.bboxEditIndex = -1;
state.bboxVisible = true;
renderBboxes(globalIdx);
updateBboxButtons();
// highlight SP card
document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight'));
if (state.parsedElements[globalIdx]) {
const card = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[globalIdx].id}"]`);
if (card) card.classList.add('highlight');
}
}
function activateBboxEdit(globalIdx) {
if (globalIdx < 0 || !state.currentImage) return;
// 双击 => 激活 bbox 编辑模式(显示拖拽/缩放手柄)
state.focusedBboxIndex = globalIdx;
state.activeBboxIndex = globalIdx;
state.bboxEditIndex = globalIdx;
state.bboxVisible = true;
renderBboxes(globalIdx);
updateBboxButtons();
// highlight SP card
document.querySelectorAll('.sp-element').forEach(el => el.classList.remove('highlight'));
if (state.parsedElements[globalIdx]) {
const card = document.querySelector(`.sp-element[data-elem-id="${state.parsedElements[globalIdx].id}"]`);
if (card) card.classList.add('highlight');
}
showToast('BBox edit mode: drag or resize', 'info');
}
// ==================== SP Field Editing ====================
function startFieldEdit(el) {
if (el.contentEditable === 'true') return;
if (el.dataset.emptyPlaceholder === '1') {
el.textContent = '';
delete el.dataset.emptyPlaceholder;
}
el.contentEditable = 'true';
el.focus();
// Select all text
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
// Save on blur or Enter
const finish = () => {
el.contentEditable = 'false';
el.removeEventListener('blur', onBlur);
el.removeEventListener('keydown', onKey);
commitFieldEdit(el);
};
const onBlur = () => finish();
const onKey = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
finish();
}
if (e.key === 'Escape') {
el.contentEditable = 'false';
el.removeEventListener('blur', onBlur);
el.removeEventListener('keydown', onKey);
// Revert — re-render
renderSPStructured();
}
};
el.addEventListener('blur', onBlur);
el.addEventListener('keydown', onKey);
}
function commitFieldEdit(el) {
const path = JSON.parse(el.dataset.path);
const newValue = el.textContent.trim();
if (!state.parsedSP) return;
pushSPUndo();
// Navigate to parent and set value
let obj = state.parsedSP;
for (let i = 0; i < path.length - 1; i++) {
obj = obj[path[i]];
if (!obj) return;
}
const lastKey = path[path.length - 1];
obj[lastKey] = newValue;
// Sync back to state
state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt);
parseSPAndUpdateBboxes();
renderSPStructured();
}
// ==================== Key Editing ====================
function startKeyEdit(el) {
if (el.contentEditable === 'true') return;
el.contentEditable = 'true';
el.focus();
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
const finish = () => {
el.contentEditable = 'false';
el.removeEventListener('blur', onBlur);
el.removeEventListener('keydown', onKey);
commitKeyEdit(el);
};
const onBlur = () => finish();
const onKey = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
finish();
}
if (e.key === 'Escape') {
el.contentEditable = 'false';
el.removeEventListener('blur', onBlur);
el.removeEventListener('keydown', onKey);
renderSPStructured();
}
};
el.addEventListener('blur', onBlur);
el.addEventListener('keydown', onKey);
}
function commitKeyEdit(el) {
const oldKey = el.dataset.oldKey;
const newKey = el.textContent.trim();
if (!newKey || newKey === oldKey || !state.parsedSP) return;
const parentPath = JSON.parse(el.dataset.path);
pushSPUndo();
// Navigate to parent
let obj = state.parsedSP;
for (let i = 0; i < parentPath.length; i++) {
obj = obj[parentPath[i]];
if (!obj) return;
}
if (typeof obj === 'object' && !Array.isArray(obj) && oldKey in obj) {
// Rename: preserve order by rebuilding object
const entries = Object.entries(obj);
const newEntries = entries.map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]);
// Clear and repopulate
for (const k of Object.keys(obj)) delete obj[k];
for (const [k, v] of newEntries) obj[k] = v;
}
state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt);
parseSPAndUpdateBboxes();
renderSPStructured();
showToast(`Field renamed: ${oldKey}${newKey}`, 'success');
}
// ==================== Add Dropdown ====================
let _addDropdownCleanup = null;
let _addDropdownAnchor = null;
let _savedAnchorRect = null; // 保存按钮位置,防止 DOM 变化后丢失
function showSectionAddDropdown(anchorEl, sectionType) {
hideAddDropdown();
hideInputPopup();
_addDropdownAnchor = anchorEl;
_savedAnchorRect = anchorEl.getBoundingClientRect();
const dd = document.getElementById('spAddDropdown');
dd.innerHTML = '';
const options = [];
if (sectionType === 'global') {
options.push({ label: 'Field', icon: '📝', action: () => promptAddField([], 'string', anchorEl) });
options.push({ label: 'Dict field', icon: '📦', action: () => promptAddField([], 'dict', anchorEl) });
} else if (sectionType === 'elements') {
options.push({ label: 'Add element', icon: '🎯', action: () => addSPElement('') });
} else if (sectionType === 'scene') {
options.push({ label: 'Field', icon: '📝', action: () => promptAddField(['scene'], 'string', anchorEl) });
options.push({ label: 'Dict field', icon: '📦', action: () => promptAddField(['scene'], 'dict', anchorEl) });
options.push({ label: 'Add element', icon: '🎯', action: () => addSPSceneElement('') });
}
options.forEach(opt => {
const item = document.createElement('div');
item.className = 'sp-add-option';
item.innerHTML = `<span>${opt.icon}</span> ${opt.label}`;
item.onclick = function(e) {
e.stopPropagation();
hideAddDropdown();
opt.action();
};
dd.appendChild(item);
});
// Position near anchor
const rect = anchorEl.getBoundingClientRect();
dd.style.top = (rect.bottom + 4) + 'px';
dd.style.left = Math.min(rect.left, window.innerWidth - 160) + 'px';
dd.style.display = 'block';
// Close on outside click
_addDropdownCleanup = (e) => {
if (!dd.contains(e.target) && e.target !== anchorEl) {
hideAddDropdown();
}
};
setTimeout(() => document.addEventListener('mousedown', _addDropdownCleanup), 0);
}
function showAddDropdown(anchorEl, basePath) {
hideAddDropdown();
hideInputPopup();
_addDropdownAnchor = anchorEl;
_savedAnchorRect = anchorEl.getBoundingClientRect();
const dd = document.getElementById('spAddDropdown');
dd.innerHTML = '';
const options = [
{ label: 'Field', icon: '📝', type: 'string' },
{ label: 'Dict field', icon: '📦', type: 'dict' },
];
options.forEach(opt => {
const item = document.createElement('div');
item.className = 'sp-add-option';
item.innerHTML = `<span>${opt.icon}</span> ${opt.label}`;
item.onclick = function(e) {
e.stopPropagation();
const savedAnchor = anchorEl; // capture before hiding
hideAddDropdown();
promptAddField(basePath, opt.type, savedAnchor);
};
dd.appendChild(item);
});
// Position near anchor
const rect = anchorEl.getBoundingClientRect();
dd.style.top = (rect.bottom + 4) + 'px';
dd.style.left = Math.min(rect.left, window.innerWidth - 160) + 'px';
dd.style.display = 'block';
// Close on outside click
_addDropdownCleanup = (e) => {
if (!dd.contains(e.target) && e.target !== anchorEl) {
hideAddDropdown();
}
};
setTimeout(() => document.addEventListener('mousedown', _addDropdownCleanup), 0);
}
function hideAddDropdown() {
const dd = document.getElementById('spAddDropdown');
if (dd) dd.style.display = 'none';
if (_addDropdownCleanup) {
document.removeEventListener('mousedown', _addDropdownCleanup);
_addDropdownCleanup = null;
}
}
// ==================== Floating Input Popup ====================
let _inputPopupEl = null;
let _inputPopupCleanup = null;
let _lastPopupAnchor = null;
function resolvePopupAnchor(anchorEl) {
const candidates = [anchorEl, _addDropdownAnchor, _lastPopupAnchor];
for (const candidate of candidates) {
if (!candidate || !document.body.contains(candidate)) continue;
const rect = candidate.getBoundingClientRect();
if (rect.width > 0 || rect.height > 0) return candidate;
}
return null;
}
function showInputPopup(anchorEl, title, fields, onSubmit) {
hideInputPopup();
hideAddDropdown();
const resolvedAnchor = resolvePopupAnchor(anchorEl);
const popup = document.createElement('div');
popup.className = 'sp-input-popup';
// Title
const titleEl = document.createElement('div');
titleEl.className = 'popup-title';
titleEl.textContent = title;
popup.appendChild(titleEl);
// Fields
const inputs = {};
fields.forEach(f => {
const fieldDiv = document.createElement('div');
fieldDiv.className = 'popup-field';
const label = document.createElement('label');
label.textContent = f.label;
fieldDiv.appendChild(label);
const input = document.createElement('input');
input.type = 'text';
input.placeholder = f.placeholder || '';
input.dataset.fieldKey = f.key;
input.addEventListener('input', () => {
input.classList.remove('input-error');
});
fieldDiv.appendChild(input);
inputs[f.key] = input;
popup.appendChild(fieldDiv);
});
// Actions
const actions = document.createElement('div');
actions.className = 'popup-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'popup-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = (e) => { e.stopPropagation(); hideInputPopup(); };
actions.appendChild(cancelBtn);
const okBtn = document.createElement('button');
okBtn.className = 'popup-btn popup-btn-primary';
okBtn.textContent = 'OK';
okBtn.onclick = (e) => {
e.stopPropagation();
const values = {};
let valid = true;
fields.forEach(f => {
values[f.key] = inputs[f.key].value.trim();
if (f.required && !values[f.key]) {
inputs[f.key].classList.add('input-error');
valid = false;
}
});
if (!valid) return;
hideInputPopup();
onSubmit(values);
};
actions.appendChild(okBtn);
popup.appendChild(actions);
// Position near anchor (with viewport clamping and fallback)
document.body.appendChild(popup);
_inputPopupEl = popup;
// 优先用 resolvedAnchor 的实时位置,否则用之前保存的 _savedAnchorRect
let rect;
if (resolvedAnchor) {
rect = resolvedAnchor.getBoundingClientRect();
} else if (_savedAnchorRect && (_savedAnchorRect.width > 0 || _savedAnchorRect.height > 0)) {
rect = _savedAnchorRect;
} else {
rect = { top: 0, left: 0, width: 0, height: 0, bottom: 0 };
}
const popupRect = popup.getBoundingClientRect();
const popupW = popupRect.width || 260;
const popupH = popupRect.height || 150;
let top, left;
// If anchor has zero size (detached from DOM or hidden), center in viewport
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
top = Math.max(10, (window.innerHeight - popupH) / 2);
left = Math.max(10, (window.innerWidth - popupW) / 2);
} else {
top = rect.bottom + 4;
left = rect.left;
}
// Clamp to viewport bounds
if (top + popupH > window.innerHeight - 10) {
top = Math.max(10, rect.top - popupH - 4);
}
if (left + popupW > window.innerWidth - 10) {
left = Math.max(10, window.innerWidth - popupW - 10);
}
if (top < 10) top = 10;
if (left < 10) left = 10;
popup.style.top = top + 'px';
popup.style.left = left + 'px';
// Focus first input
const firstInput = popup.querySelector('input');
if (firstInput) setTimeout(() => firstInput.focus(), 50);
// Keyboard shortcuts
popup.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
okBtn.click();
}
if (e.key === 'Escape') {
hideInputPopup();
}
});
// Close on outside click
_inputPopupCleanup = (e) => {
if (!popup.contains(e.target) && (!resolvedAnchor || e.target !== resolvedAnchor)) {
hideInputPopup();
}
};
setTimeout(() => document.addEventListener('mousedown', _inputPopupCleanup), 0);
}
function hideInputPopup() {
if (_inputPopupEl) {
_inputPopupEl.remove();
_inputPopupEl = null;
}
if (_inputPopupCleanup) {
document.removeEventListener('mousedown', _inputPopupCleanup);
_inputPopupCleanup = null;
}
}
// 弹出悬浮输入框让用户填写 key 和 value
function promptAddField(basePath, type, anchorOverride) {
const anchor = anchorOverride || _addDropdownAnchor || document.body;
_lastPopupAnchor = anchor;
if (type === 'string') {
showInputPopup(anchor, 'Add field', [
{ key: 'fieldKey', label: 'Key', placeholder: 'Field name', required: true },
{ key: 'fieldValue', label: 'Value', placeholder: 'Field value', required: false },
], (values) => {
addFieldToPath(basePath, 'string', values.fieldKey, values.fieldValue);
});
} else if (type === 'dict') {
showInputPopup(anchor, 'Add dict field', [
{ key: 'dictName', label: 'Dict name', placeholder: 'Dict name', required: true },
{ key: 'innerKey', label: 'Inner key', placeholder: 'key', required: true },
{ key: 'innerValue', label: 'Inner value', placeholder: 'value', required: false },
], (values) => {
addFieldToPath(basePath, 'dict', values.dictName, null, values.innerKey, values.innerValue);
});
}
}
// 直接向字典内Add一对 key-value(通过悬浮输入框)
function promptAddDictEntry(basePath, anchorEl) {
_lastPopupAnchor = anchorEl || document.body;
if (anchorEl) _savedAnchorRect = anchorEl.getBoundingClientRect();
showInputPopup(anchorEl || document.body, 'Add field', [
{ key: 'fieldKey', label: 'Key', placeholder: 'key', required: true },
{ key: 'fieldValue', label: 'Value', placeholder: 'value', required: false },
], (values) => {
addFieldToPath(basePath, 'string', values.fieldKey, values.fieldValue);
});
}
function addFieldToPath(basePath, type, fieldKey, fieldValue, innerKey, innerValue) {
if (!state.parsedSP) return;
// Navigate to target object
let target = state.parsedSP;
for (const key of basePath) {
if (target[key] === undefined) target[key] = {};
target = target[key];
}
if (typeof target !== 'object' || Array.isArray(target)) {
showToast("Can't add a field here", 'warning');
return;
}
if (fieldKey in target) {
showToast(`Field "${fieldKey}" already exists`, 'warning');
return;
}
pushSPUndo();
if (type === 'dict') {
// 创建字典,带初始 key-value
const dictObj = {};
if (innerKey) dictObj[innerKey] = innerValue || '';
target[fieldKey] = dictObj;
} else {
target[fieldKey] = fieldValue || '';
}
state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt);
parseSPAndUpdateBboxes();
updateSPPanel();
const isEmptyValue = type === 'dict' ? (!innerValue && !!innerKey) : !fieldValue;
const emptySuffix = isEmptyValue ? ' (empty value)' : '';
showToast(`Added ${type === 'dict' ? 'dict ' : ''}field: ${fieldKey}${emptySuffix}`, 'success');
}
// ==================== Section Clear ====================
function clearSectionFields(sectionType) {
if (!state.parsedSP) return;
pushSPUndo();
if (sectionType === 'global') {
// Remove all top-level keys except elements and scene
Object.keys(state.parsedSP).forEach(key => {
if (key !== 'elements' && key !== 'scene') {
delete state.parsedSP[key];
}
});
showToast('Cleared Global fields', 'info');
} else if (sectionType === 'elements') {
// Remove整个 elements key
delete state.parsedSP.elements;
showToast('Removed Elements', 'info');
} else if (sectionType === 'scene') {
// Remove整个 scene key
delete state.parsedSP.scene;
showToast('Removed Scene', 'info');
}
state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt);
parseSPAndUpdateBboxes();
updateSPPanel();
}
function addSPElement(caption) {
if (!state.parsedSP) return;
if (!state.parsedSP.elements) state.parsedSP.elements = [];
pushSPUndo();
// 自动递增 ID
let maxId = 0;
state.parsedSP.elements.forEach(e => {
const id = parseInt(e.id) || 0;
if (id > maxId) maxId = id;
});
if (state.parsedSP.scene && state.parsedSP.scene.elements) {
state.parsedSP.scene.elements.forEach(e => {
const id = parseInt(e.id) || 0;
if (id > maxId) maxId = id;
});
}
const newElem = {
id: maxId + 1,
caption: caption || '',
position: '<bbox>0 0 500 500</bbox>',
};
state.parsedSP.elements.push(newElem);
state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt);
parseSPAndUpdateBboxes();
updateSPPanel();
showToast(`Added element (id: ${newElem.id})`, 'success');
}
function addSPSceneElement(caption) {
if (!state.parsedSP) return;
if (!state.parsedSP.scene) state.parsedSP.scene = {};
if (!state.parsedSP.scene.elements) state.parsedSP.scene.elements = [];
pushSPUndo();
let maxId = 0;
if (state.parsedSP.elements) {
state.parsedSP.elements.forEach(e => {
const id = parseInt(e.id) || 0;
if (id > maxId) maxId = id;
});
}
state.parsedSP.scene.elements.forEach(e => {
const id = parseInt(e.id) || 0;
if (id > maxId) maxId = id;
});
const newElem = {
id: maxId + 1,
caption: caption || '',
position: '<bbox>0 0 500 500</bbox>',
};
state.parsedSP.scene.elements.push(newElem);
state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt);
parseSPAndUpdateBboxes();
updateSPPanel();
showToast(`Added scene element (id: ${newElem.id})`, 'success');
}
// ==================== Deleting Fields ====================
function deleteSPField(path) {
if (!state.parsedSP) return;
if (typeof path === 'string') path = JSON.parse(path);
pushSPUndo();
let obj = state.parsedSP;
for (let i = 0; i < path.length - 1; i++) {
obj = obj[path[i]];
if (!obj) return;
}
const lastKey = path[path.length - 1];
if (Array.isArray(obj)) {
obj.splice(lastKey, 1);
} else {
delete obj[lastKey];
}
state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt);
parseSPAndUpdateBboxes();
updateSPPanel();
showToast('Removed', 'info');
}
// ==================== Generation Logic (per DEFINITION.md) ====================
/**
* Generate SP按钮逻辑:
* - 当Image不存在时: User Prompt => SP (Text System Prompt)
* - 当Image存在时:
* - H&W会被覆盖
* - 没有User Prompt => 根据图像Generate SP (Image System Prompt)
* - 只有User Prompt (无SP) => 根据图像Generate SP (Image System Prompt)
* - 有User Prompt和SP => 根据User Prompt修改SP (Editing System Prompt)
*/
async function handleGenerateSP() {
if (state.isGenerating) return;
const hasImage = !!state.currentImage;
const hasUserPrompt = !!state.userPrompt.trim();
const hasSP = !!state.structuredPrompt.trim();
if (!hasImage) {
// ---- Text → SP ----
if (!hasUserPrompt) {
showToast('Please enter a User Prompt', 'warning');
return;
}
setGenerating(true, 'sp');
showToast('Generating Structured Prompt from User Prompt...', 'info');
try {
const result = await callLLMForSP({
type: 'text',
userPrompt: state.userPrompt,
});
if (result) {
setSPContent(result);
showToast('Structured Prompt generated', 'success');
}
addHistoryEntry('sp');
} catch (err) {
showToast('Generation failed: ' + err.message, 'error');
} finally {
setGenerating(false);
}
} else {
// ---- Image exists ----
autoUpdateHW(); // H&W被覆盖
if (hasUserPrompt && hasSP) {
// Case 3: 有 User Prompt 和 SP → 修改 SP
setGenerating(true, 'sp');
showToast('Editing Structured Prompt from User Prompt...', 'info');
try {
const result = await callLLMForSP({
type: 'edit',
image: state.currentImage.src,
userPrompt: state.userPrompt,
structuredPrompt: state.structuredPrompt,
});
if (result) {
setSPContent(result);
showToast('Structured Prompt updated', 'success');
}
addHistoryEntry('sp');
} catch (err) {
showToast('Edit failed: ' + err.message, 'error');
} finally {
setGenerating(false);
}
} else {
// Case 1 & 2: 根据图像生成 SP
setGenerating(true, 'sp');
showToast('Generating Structured Prompt from image...', 'info');
try {
const result = await callLLMForSP({
type: 'image',
image: state.currentImage.src,
});
if (result) {
setSPContent(result);
showToast('Structured Prompt generated', 'success');
}
addHistoryEntry('sp');
} catch (err) {
showToast('Generation failed: ' + err.message, 'error');
} finally {
setGenerating(false);
}
}
}
}
/**
* Generate Image按钮逻辑:
* - 当Image不存在时 (T2I):
* - 有SP => 根据SP生成
* - 无SP => 根据User Prompt生成
* - 当Image存在时 ((I+T)2I):
* - 有SP => 根据SP修改图像
* - 无SP => 根据User Prompt修改图像
*
* 生成前: 与上轮对比 User & SP,高亮修改,点击确认才提交
*/
async function handleGenerateImage() {
if (state.isGenerating) return;
const hasImage = !!state.currentImage && !state.params.forceT2I;
const hasUserPrompt = !!state.userPrompt.trim();
const hasSP = !!state.structuredPrompt.trim();
if (!hasSP && !hasUserPrompt) {
showToast('Please enter a User Prompt or Structured Prompt', 'warning');
return;
}
syncParamsFromUI();
let prompt, message;
if (!hasImage) {
// T2I
if (hasSP) {
prompt = state.structuredPrompt;
message = 'Generating image from Structured Prompt...';
} else {
prompt = state.userPrompt;
message = 'Generating image from User Prompt...';
}
} else {
// (I+T)2I
if (hasSP) {
prompt = state.structuredPrompt;
message = 'Editing image with Structured Prompt...';
} else {
prompt = state.userPrompt;
message = 'Editing image with User Prompt...';
}
}
// 如果有历史记录,展示 diff 对比
if (state.history.length > 0) {
const lastEntry = state.history[state.history.length - 1];
const upChanged = (lastEntry.userPrompt || '') !== (state.userPrompt || '');
const spChanged = (lastEntry.structuredPrompt || '') !== (state.structuredPrompt || '');
if (upChanged || spChanged) {
showDiffDialog(lastEntry, () => {
doGenerateImage(prompt, message, hasImage);
});
return;
}
}
doGenerateImage(prompt, message, hasImage);
}
async function doGenerateImage(prompt, message, hasImage) {
setGenerating(true, 'image');
showToast(message, 'info');
try {
const result = await callDiTForImage({
type: hasImage ? 'i2i' : 't2i',
prompt: prompt,
image: hasImage ? state.currentImage.src : null,
params: { ...state.params },
});
if (result) {
// result: image data URL or URL — 替换当前图片
const img = new Image();
img.onload = function () {
state.currentImage = {
src: result,
width: img.naturalWidth,
height: img.naturalHeight,
};
updateImagePanel();
addHistoryEntry('image');
showToast('Image generated', 'success');
};
img.onerror = function () {
showToast('Failed to load generated image', 'error');
};
img.src = result;
} else {
// DiT 未实现时: 直接将当前状态存入历史(Debug 模式)
addHistoryEntry('image');
showToast('Current state saved to history (debug)', 'success');
}
} catch (err) {
showToast('Image generation failed: ' + err.message, 'error');
} finally {
setGenerating(false);
}
}
function setGenerating(isGenerating, type) {
state.isGenerating = isGenerating;
const spBtn = document.getElementById('genSPBtn');
const imgBtn = document.getElementById('genImageBtn');
if (isGenerating) {
spBtn.disabled = true;
imgBtn.disabled = true;
if (type === 'sp') {
spBtn.classList.add('loading');
spBtn.innerHTML = '<span class="spinner"></span>Generating...';
} else if (type === 'image') {
imgBtn.classList.add('loading');
imgBtn.innerHTML = '<span class="spinner"></span>Generating...';
}
} else {
spBtn.disabled = false;
imgBtn.disabled = false;
spBtn.classList.remove('loading');
imgBtn.classList.remove('loading');
spBtn.textContent = 'Generate SP';
imgBtn.textContent = 'Generate Image';
}
}
// ==================== Stub Functions (To Be Implemented) ====================
/**
* 调用 LLM 生成 / 修改 Structured Prompt
*
* @param {Object} options
* @param {string} options.type - 'text' | 'image' | 'edit'
*
* type='text': 纯文本 → SP
* options.userPrompt: 用户输入
* 使用 Text System Prompt (DEFAULT_SYSTEM_PROMPT)
*
* type='image': 图片 → SP
* options.image: 图片 data URL
* 使用 Image System Prompt
*
* type='edit': 根据指令修改 SP
* options.image: 图片 data URL
* options.userPrompt: 修改指令
* options.structuredPrompt: 当前 SP
* 使用 Editing System Prompt
*
* @returns {string|null} 生成的 SP (formatted JSON string) 或 null
*/
async function callLLMForSP(options) {
// Streams the PE model's tokens via SSE into the SP editor's raw
// textarea (so the user watches the CoT + JSON appear live), then
// once the `done` frame arrives, swaps in the extracted SP JSON.
if (options.type !== 'text') {
showToast('That mode (image / edit) is not supported yet — text → SP only', 'warning');
return null;
}
const width = (Math.round((state.params.width || 1024) / 32) * 32) || 1024;
const height = (Math.round((state.params.height || 1024) / 32) * 32) || 1024;
// Prep the SP textarea for streaming — flip into raw mode, empty it.
const spTextarea = document.getElementById('spTextarea');
const spEmpty = document.getElementById('spEmpty');
const spStructured = document.getElementById('spStructured');
const prevRaw = state.spRawMode;
state.spRawMode = true;
if (spTextarea) {
spTextarea.style.display = '';
spTextarea.value = '';
}
if (spEmpty) spEmpty.style.display = 'none';
if (spStructured) spStructured.style.display = 'none';
try {
const resp = await fetch('/api/generate_sp/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_prompt: options.userPrompt || '',
width: width, height: height,
}),
});
if (!resp.ok || !resp.body) {
const errText = await resp.text().catch(() => resp.statusText);
throw new Error(`HTTP ${resp.status}: ${errText}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = '';
let finalSp = null;
let streamError = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
sseBuffer += decoder.decode(value, { stream: true });
// Split on blank line — each block is one SSE event.
let idx;
while ((idx = sseBuffer.indexOf('\n\n')) !== -1) {
const block = sseBuffer.slice(0, idx);
sseBuffer = sseBuffer.slice(idx + 2);
let evName = 'message', dataStr = '';
for (const line of block.split('\n')) {
if (line.startsWith('event: ')) evName = line.slice(7).trim();
else if (line.startsWith('data: ')) dataStr += line.slice(6);
}
if (!dataStr) continue;
let payload;
try { payload = JSON.parse(dataStr); } catch { continue; }
if (evName === 'token' && spTextarea && payload.text) {
spTextarea.value += payload.text;
spTextarea.scrollTop = spTextarea.scrollHeight;
} else if (evName === 'waking') {
showToast(payload.msg || 'PE endpoint cold-starting…', 'info');
} else if (evName === 'done') {
finalSp = payload.sp;
} else if (evName === 'error') {
streamError = payload.error || 'unknown stream error';
}
}
}
if (streamError) throw new Error(streamError);
if (finalSp == null) throw new Error('stream closed without result');
// Restore raw-mode preference before returning; setSPContent will
// populate the structured view.
state.spRawMode = prevRaw;
return typeof finalSp === 'string' ? finalSp : JSON.stringify(finalSp, null, 2);
} catch (err) {
console.error('[LLM] generate_sp stream failed:', err);
state.spRawMode = prevRaw;
showToast('SP generation failed: ' + err.message, 'error');
return null;
}
}
// ==================== DiT Generation (QwenImage Serving API) ====================
/**
* 将 JSON 对象转为 compact single-quote 格式字符串。
* 服务端 prompt 字段期望此格式的 Structured Prompt。
*/
function compactSingleQuoteJSON(data) {
const PH_S = '@@SQ@@', PH_D = '@@DQ@@';
const reS = /@@SQ@@/g, reD = /@@DQ@@/g;
function protect(obj) {
if (obj === null || obj === undefined) return obj;
if (Array.isArray(obj)) return obj.map(protect);
if (typeof obj === 'object') {
const r = {};
for (const [k, v] of Object.entries(obj)) {
const pk = typeof k === 'string' ? k.replace(/'/g, PH_S).replace(/"/g, PH_D) : k;
r[pk] = protect(v);
}
return r;
}
if (typeof obj === 'string') return obj.replace(/'/g, PH_S).replace(/"/g, PH_D);
return obj;
}
const jsonStr = JSON.stringify(protect(data));
return jsonStr.replace(/"/g, "'").replace(reS, "\\'").replace(reD, '"');
}
/**
* Call the local FastAPI backend which runs the QwenImage DiT in-process.
* See demo/backend/dit.py for the model wrapper.
*
* @param {Object} options
* @param {string} options.prompt - text prompt (SP JSON string or plain NL)
* @param {Object} options.params - { cfgScale, steps, height, width, seed }
*
* @returns {string|null} generated image data URL, or null on failure.
*/
async function callDiTForImage(options) {
const { prompt, params } = options;
const width = (Math.round((params.width || 1024) / 32) * 32) || 1024;
const height = (Math.round((params.height || 1024) / 32) * 32) || 1024;
// SP JSON → compact single-quote format expected by the DiT; plain text as-is.
let formattedPrompt;
try {
const parsed = typeof prompt === 'string' ? JSON.parse(prompt) : prompt;
formattedPrompt = compactSingleQuoteJSON(parsed);
} catch (_) {
formattedPrompt = prompt;
}
const body = {
prompt: formattedPrompt,
height: height,
width: width,
num_steps: params.steps || 25,
seed: (params.seed != null && params.seed !== -1) ? params.seed : 42,
cfg_scale: params.cfgScale || 4.0,
negative_prompt: "",
};
try {
// POST kicks off a background DiT job on the Space (bypasses HF's
// 10-min HTTP/2 keep-alive cap — cold model load is 5min, first
// generation another 40s). Then poll /api/task/{id}.
const kickResp = await fetch('/api/generate_image_async', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!kickResp.ok) {
const errText = await kickResp.text().catch(() => kickResp.statusText);
throw new Error(`HTTP ${kickResp.status}: ${errText}`);
}
const { task_id } = await kickResp.json();
if (!task_id) throw new Error('Response missing task_id');
const startedAt = Date.now();
const timeoutMs = 20 * 60 * 1000; // 20-min ceiling
while (true) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error('DiT job timed out after 20 minutes');
}
await simulateDelay(2000);
let pollResp;
try {
pollResp = await fetch(`/api/task/${task_id}`);
} catch (_) {
continue; // transient network hiccup — keep trying
}
if (!pollResp.ok) continue;
const st = await pollResp.json();
if (st.status === 'done' && st.image_base64) {
return 'data:image/png;base64,' + st.image_base64;
}
if (st.status === 'failed') {
throw new Error(st.error || 'DiT failed');
}
// status === 'running' — surface elapsed time on the button
const elapsed = Math.round((Date.now() - startedAt) / 1000);
const btn = document.getElementById('genImageBtn');
if (btn) {
const label = btn.querySelector('.btn-label') || btn;
label.textContent = `Generating… ${elapsed}s`;
}
}
} catch (err) {
console.error('[DiT] generate_image failed:', err);
showToast('Image generation failed: ' + err.message, 'error');
return null;
}
}
function simulateDelay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ==================== Diff Dialog ====================
function showDiffDialog(lastEntry, onConfirm) {
state.pendingGeneration = onConfirm;
const diffContent = document.getElementById('diffContent');
let html = '';
// User Prompt diff
html += '<div class="diff-section-title">User Prompt changes</div>';
html += '<div class="diff-block">';
html += generateDiffHTML(lastEntry.userPrompt || '', state.userPrompt || '');
html += '</div>';
// SP diff
html += '<div class="diff-section-title">Structured Prompt changes</div>';
html += '<div class="diff-block">';
html += generateDiffHTML(lastEntry.structuredPrompt || '', state.structuredPrompt || '');
html += '</div>';
diffContent.innerHTML = html;
document.getElementById('diffModal').classList.add('visible');
}
function generateDiffHTML(oldText, newText) {
if (oldText === newText) {
return '<div class="diff-no-change">No changes</div>';
}
const oldLines = oldText.split('\n');
const newLines = newText.split('\n');
let html = '';
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const oldLine = i < oldLines.length ? oldLines[i] : undefined;
const newLine = i < newLines.length ? newLines[i] : undefined;
if (oldLine === newLine) {
html += `<div class="diff-line diff-same">&nbsp;${escapeHtml(oldLine)}</div>`;
} else {
if (oldLine !== undefined) {
html += `<div class="diff-line diff-removed">-${escapeHtml(oldLine)}</div>`;
}
if (newLine !== undefined) {
html += `<div class="diff-line diff-added">+${escapeHtml(newLine)}</div>`;
}
}
}
return html;
}
function escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function cancelGeneration() {
state.pendingGeneration = null;
document.getElementById('diffModal').classList.remove('visible');
}
function confirmGeneration() {
document.getElementById('diffModal').classList.remove('visible');
if (state.pendingGeneration) {
const fn = state.pendingGeneration;
state.pendingGeneration = null;
fn();
}
}
// ==================== History ====================
function addHistoryEntry(type) {
const entry = {
type: type, // 'sp' or 'image'
inputImage: state.currentImage ? state.currentImage.src : null,
userPrompt: state.userPrompt || '',
structuredPrompt: state.structuredPrompt || '',
params: { ...state.params },
timestamp: Date.now(),
colorIndex: state.generationCount % HISTORY_COLORS.length,
};
state.history.push(entry);
state.generationCount++;
renderHistory();
}
function renderHistory() {
const scroll = document.getElementById('historyScroll');
const empty = document.getElementById('historyEmpty');
const clearBtn = document.getElementById('historyClearBtn');
// 先清除已有 DOM 元素
scroll.querySelectorAll('.history-item, .history-separator').forEach(el => el.remove());
if (state.history.length === 0) {
empty.style.display = '';
clearBtn.style.display = 'none';
return;
}
empty.style.display = 'none';
clearBtn.style.display = '';
let prevColorIndex = -1;
let prevOutputImage = null;
state.history.forEach((entry, index) => {
// Separator between different generation groups
if (index > 0 && entry.colorIndex !== prevColorIndex && prevColorIndex !== -1) {
const sep = document.createElement('div');
sep.className = 'history-separator';
scroll.appendChild(sep);
}
prevColorIndex = entry.colorIndex;
const item = document.createElement('div');
item.className = `history-item ${HISTORY_COLORS[entry.colorIndex]}`;
item.onclick = () => showHistoryDetail(index);
item.title = `#${index + 1} ${entry.type === 'sp' ? 'Generate SP' : 'Generate Image'}`;
// Thumbnail
if (entry.inputImage) {
const isRepeated = prevOutputImage && entry.inputImage === prevOutputImage;
const thumb = document.createElement('img');
thumb.className = 'history-thumb' + (isRepeated ? ' repeated' : '');
thumb.src = entry.inputImage;
item.appendChild(thumb);
}
// Type label
const typeLabel = document.createElement('span');
typeLabel.className = 'history-type-label';
typeLabel.textContent = entry.type === 'sp' ? 'SP' : 'IMG';
item.appendChild(typeLabel);
// Prompt badge
if (entry.userPrompt) {
const badge = document.createElement('span');
badge.className = 'history-badge badge-p';
badge.textContent = 'P';
badge.title = entry.userPrompt.substring(0, 100);
item.appendChild(badge);
}
// SP badge
if (entry.structuredPrompt) {
const sBadge = document.createElement('span');
sBadge.className = 'history-badge badge-s';
sBadge.textContent = 'S';
sBadge.title = 'Structured Prompt';
item.appendChild(sBadge);
}
// Config badge
const cfgBadge = document.createElement('span');
cfgBadge.className = 'history-badge badge-c';
cfgBadge.textContent = 'C';
cfgBadge.title = `CFG:${entry.params.cfgScale || entry.params.cfgText || entry.params.cfg || '-'} Step:${entry.params.steps} ${entry.params.width}×${entry.params.height} Seed:${entry.params.seed}`;
item.appendChild(cfgBadge);
scroll.appendChild(item);
// Track for "repeated" detection
if (entry.type === 'image' && entry.inputImage) {
prevOutputImage = entry.inputImage;
}
});
// Scroll to end
requestAnimationFrame(() => {
scroll.scrollLeft = scroll.scrollWidth;
});
}
function clearHistory() {
if (state.history.length === 0) return;
if (!confirm('Clear all history and reset every input?')) return;
// 清空历史
state.history = [];
state.generationCount = 0;
renderHistory();
// 清空图片
state.currentImage = null;
state.bboxVisible = false;
state.activeBboxIndex = -1;
state.bboxEditIndex = -1;
state.focusedBboxIndex = -1;
state.parsedElements = [];
clearBboxes();
updateImagePanel();
// 清空 Structured Prompt
state.structuredPrompt = '';
state.parsedSP = null;
state.undoStack = [];
updateSPPanel();
// 清空 User Prompt
state.userPrompt = '';
const promptInput = document.getElementById('userPromptInput');
if (promptInput) promptInput.value = '';
showToast('Cleared all history and inputs', 'info');
}
function showHistoryDetail(index) {
state.selectedHistoryIndex = index;
const entry = state.history[index];
const body = document.getElementById('historyDetailBody');
let html = '';
// Meta
html += `<div class="detail-section">`;
html += `<div class="detail-section-title">Basic info</div>`;
html += `<div class="detail-section-content">`;
html += `Type: ${entry.type === 'sp' ? 'Generate Structured Prompt' : 'Generate image'}\n`;
html += `Time: ${new Date(entry.timestamp).toLocaleString()}`;
html += `</div></div>`;
// Image
if (entry.inputImage) {
html += `<div class="detail-section">`;
html += `<div class="detail-section-title">Input image</div>`;
html += `<img class="detail-image" src="${entry.inputImage}" />`;
html += `</div>`;
}
// User Prompt
if (entry.userPrompt) {
html += `<div class="detail-section">`;
html += `<div class="detail-section-title">User Prompt</div>`;
html += `<div class="detail-section-content">${escapeHtml(entry.userPrompt)}</div>`;
html += `</div>`;
}
// Structured Prompt
if (entry.structuredPrompt) {
html += `<div class="detail-section">`;
html += `<div class="detail-section-title">Structured Prompt</div>`;
html += `<div class="detail-section-content mono">${escapeHtml(entry.structuredPrompt)}</div>`;
html += `</div>`;
}
// Params
const cfgVal = entry.params.cfgScale || entry.params.cfgText || entry.params.cfg || '-';
html += `<div class="detail-section">`;
html += `<div class="detail-section-title">Generation params</div>`;
html += `<div class="detail-section-content">`;
html += `CFG: ${cfgVal} Step: ${entry.params.steps} Size: ${entry.params.width} × ${entry.params.height} Seed: ${entry.params.seed}\n`;
html += `</div></div>`;
body.innerHTML = html;
document.getElementById('historyModal').classList.add('visible');
}
function closeHistoryModal() {
document.getElementById('historyModal').classList.remove('visible');
}
function restoreFromHistory() {
const index = state.selectedHistoryIndex;
if (index < 0 || index >= state.history.length) return;
const entry = state.history[index];
// Restore image
if (entry.inputImage) {
loadImageFromSrc(entry.inputImage);
} else {
state.currentImage = null;
updateImagePanel();
}
// Restore User Prompt
state.userPrompt = entry.userPrompt || '';
document.getElementById('userPromptInput').value = state.userPrompt;
// Restore SP
setSPContent(entry.structuredPrompt || '');
// Restore params
state.params = { ...entry.params };
syncParamsToUI();
closeHistoryModal();
showToast('Restored history entry #' + (index + 1), 'success');
}
function restoreImageFromHistory() {
const index = state.selectedHistoryIndex;
if (index < 0 || index >= state.history.length) return;
const entry = state.history[index];
if (entry.inputImage) {
loadImageFromSrc(entry.inputImage);
autoUpdateHW();
} else {
state.currentImage = null;
updateImagePanel();
}
closeHistoryModal();
showToast('Image restored', 'success');
}
function restorePromptFromHistory() {
const index = state.selectedHistoryIndex;
if (index < 0 || index >= state.history.length) return;
const entry = state.history[index];
state.userPrompt = entry.userPrompt || '';
document.getElementById('userPromptInput').value = state.userPrompt;
setSPContent(entry.structuredPrompt || '');
closeHistoryModal();
showToast('Prompt restored', 'success');
}
function restoreParamsFromHistory() {
const index = state.selectedHistoryIndex;
if (index < 0 || index >= state.history.length) return;
const entry = state.history[index];
state.params = { ...entry.params };
syncParamsToUI();
closeHistoryModal();
showToast('Params restored', 'success');
}
// ==================== Toast Notifications ====================
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'toast-out 0.3s ease forwards';
setTimeout(() => {
if (toast.parentNode) toast.remove();
}, 300);
}, 3000);
}
// ==================== BBox Drag & Resize ====================
function initBboxDrag(e, elemIndex, handle) {
const container = document.getElementById('bboxContainer');
const containerRect = container.getBoundingClientRect();
const elem = state.parsedElements[elemIndex];
if (!elem || !elem.bbox) return;
state.bboxDrag = {
elemIndex,
handle,
startX: e.clientX,
startY: e.clientY,
startBbox: [...elem.bbox],
containerRect,
};
document.addEventListener('mousemove', onBboxDrag);
document.addEventListener('mouseup', endBboxDrag);
}
function onBboxDrag(e) {
const drag = state.bboxDrag;
if (!drag) return;
const { elemIndex, handle, startX, startY, startBbox, containerRect } = drag;
const dx = ((e.clientX - startX) / containerRect.width) * 1000;
const dy = ((e.clientY - startY) / containerRect.height) * 1000;
let [x1, y1, x2, y2] = startBbox;
if (handle === 'move') {
const w = x2 - x1, h = y2 - y1;
x1 = Math.max(0, Math.min(1000 - w, x1 + dx));
y1 = Math.max(0, Math.min(1000 - h, y1 + dy));
x2 = x1 + w;
y2 = y1 + h;
} else {
// Resize
if (handle.includes('n')) y1 = Math.max(0, Math.min(y2 - 10, y1 + dy));
if (handle.includes('s')) y2 = Math.max(y1 + 10, Math.min(1000, y2 + dy));
if (handle.includes('w')) x1 = Math.max(0, Math.min(x2 - 10, x1 + dx));
if (handle.includes('e')) x2 = Math.max(x1 + 10, Math.min(1000, x2 + dx));
}
// Round
x1 = Math.round(x1); y1 = Math.round(y1);
x2 = Math.round(x2); y2 = Math.round(y2);
// Update visual position
const box = document.querySelector(`.bbox-box[data-elem-index="${elemIndex}"]`);
if (box) {
box.style.left = (x1 / 1000 * 100) + '%';
box.style.top = (y1 / 1000 * 100) + '%';
box.style.width = ((x2 - x1) / 1000 * 100) + '%';
box.style.height = ((y2 - y1) / 1000 * 100) + '%';
}
// Store temp bbox for commit
drag.currentBbox = [x1, y1, x2, y2];
}
function endBboxDrag(e) {
document.removeEventListener('mousemove', onBboxDrag);
document.removeEventListener('mouseup', endBboxDrag);
const drag = state.bboxDrag;
if (!drag) return;
const newBbox = drag.currentBbox || drag.startBbox;
const elemIndex = drag.elemIndex;
state.bboxDrag = null;
// Check if actually changed
const old = drag.startBbox;
const moved = old[0] !== newBbox[0] || old[1] !== newBbox[1] || old[2] !== newBbox[2] || old[3] !== newBbox[3];
if (!moved) {
// Was a click, not a drag — handle bbox click with overlap cycling
handleBboxClick(elemIndex, e);
return;
}
// Update parsed element
state.parsedElements[elemIndex].bbox = newBbox;
// Update SP JSON
updateSPPosition(elemIndex, newBbox);
}
function updateSPPosition(elemIndex, newBbox) {
if (!state.parsedSP) return;
pushSPUndo();
const elem = state.parsedElements[elemIndex];
const newPosStr = `<bbox>${newBbox.join(' ')}</bbox>`;
// Find and update in elements array
let updated = false;
if (state.parsedSP.elements) {
for (const e of state.parsedSP.elements) {
if (e.id === elem.id && e.position) {
e.position = newPosStr;
updated = true;
break;
}
}
}
if (!updated && state.parsedSP.scene && state.parsedSP.scene.elements) {
for (const e of state.parsedSP.scene.elements) {
if (e.id === elem.id && e.position) {
e.position = newPosStr;
break;
}
}
}
state.structuredPrompt = stableJSONStringify(state.parsedSP, state.structuredPrompt);
// Re-render structured view if visible
if (!state.spRawMode) {
renderSPStructured();
} else {
document.getElementById('spTextarea').value = state.structuredPrompt;
}
}
// ==================== Window Events ====================
window.addEventListener('resize', () => {
if (state.bboxVisible && state.currentImage) {
renderBboxes();
}
});
</script>
</body>
</html>