Download scripts/index.html from cdkkkk/setup: direct link, hf CLI and curl.
- Browser
- Download file 327 kB
-
https://huggingface.co/cdkkkk/setup/resolve/main/scripts/index.html
- Command line
-
hf download hf://cdkkkk/setup/scripts/index.html
-
curl -L -o index.html https://huggingface.co/cdkkkk/setup/resolve/main/scripts/index.html
327 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>aiprompt-marie-kondo — Image & Prompt Manager</title> | |
| <style> | |
| :root { | |
| --bg: #14161a; | |
| --bg-elev: #1c1f26; | |
| --bg-cell: #23272f; | |
| --border: #2e333d; | |
| --text: #e7e9ee; | |
| --text-dim: #9aa1ad; | |
| --accent: #4f8cff; | |
| --accent-dim: #2c4a80; | |
| --ok: #37c07a; | |
| --ok-dim: #1e5a3c; | |
| --none: #6b7280; | |
| --warn: #e0a13a; | |
| --error: #e5484d; | |
| --dirty: #e0a13a; | |
| --star: #f2c14e; | |
| --cell-radius: 8px; | |
| font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| } | |
| * { box-sizing: border-box; } | |
| /* `hidden` must win even over flex/grid/absolute display rules below. */ | |
| [hidden] { display: none ; } | |
| html, body { | |
| margin: 0; | |
| height: 100%; | |
| background: var(--bg); | |
| color: var(--text); | |
| overflow: hidden; | |
| } | |
| body { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* ---------- Buttons & inputs ---------- */ | |
| .btn { | |
| background: var(--bg-cell); | |
| color: var(--text); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 7px 12px; | |
| font-size: 13px; | |
| cursor: pointer; | |
| line-height: 1; | |
| white-space: nowrap; | |
| } | |
| .btn:hover { border-color: var(--accent-dim); } | |
| .btn:active { transform: translateY(1px); } | |
| .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } | |
| .btn.primary:hover { filter: brightness(1.08); } | |
| .btn.danger:hover { border-color: var(--error); color: var(--error); } | |
| .btn:disabled { opacity: 0.45; cursor: default; } | |
| /* ---------- Overflow menu ("⋮") ---------- | |
| Used by the prompt-editor header and every scene header: the occasional | |
| actions live here so a header row never has to wrap. */ | |
| .action-menu { position: relative; } | |
| .action-menu-btn { padding: 7px 9px; font-size: 14px; } | |
| .action-menu-list { | |
| position: absolute; | |
| top: calc(100% + 4px); | |
| right: 0; | |
| z-index: 5; | |
| min-width: 190px; | |
| display: flex; | |
| flex-direction: column; | |
| padding: 4px; | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| background: var(--bg-cell); | |
| box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5); | |
| } | |
| /* Near the bottom of the scrolling list the menu opens upwards (openActionMenu). */ | |
| .action-menu.flip-up .action-menu-list { top: auto; bottom: calc(100% + 4px); } | |
| .action-menu-item { | |
| border: none; | |
| border-radius: 6px; | |
| background: transparent; | |
| color: var(--text); | |
| padding: 7px 10px; | |
| font-size: 13px; | |
| text-align: left; | |
| white-space: nowrap; | |
| cursor: pointer; | |
| } | |
| .action-menu-item:hover:not(:disabled) { background: var(--accent-dim); } | |
| .action-menu-item:disabled { opacity: 0.45; cursor: default; } | |
| .action-menu-item.danger { color: var(--error); } | |
| .action-menu-item.danger:hover:not(:disabled) { background: rgba(229, 72, 77, 0.16); } | |
| .search { | |
| width: 100%; | |
| max-width: 520px; | |
| background: var(--bg-cell); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text); | |
| padding: 8px 12px; | |
| font-size: 13px; | |
| outline: none; | |
| } | |
| .search:focus { border-color: var(--accent); } | |
| /* ---------- App nav (section switch) ---------- */ | |
| #appnav { | |
| display: flex; | |
| align-items: center; | |
| flex-wrap: wrap; /* narrow windows: wrap instead of pushing buttons off screen */ | |
| gap: 20px; | |
| padding: 8px 14px; | |
| background: #0f1115; | |
| border-bottom: 1px solid var(--border); | |
| flex: 0 0 auto; | |
| } | |
| .brand { font-weight: 700; font-size: 14px; } | |
| .sections { display: flex; gap: 4px; } | |
| .tab { | |
| background: transparent; | |
| border: 1px solid transparent; | |
| color: var(--text-dim); | |
| padding: 6px 12px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| } | |
| .tab:hover { color: var(--text); } | |
| .tab.active { background: var(--bg-cell); border-color: var(--border); color: var(--text); } | |
| /* ---------- Sections ---------- */ | |
| .section { | |
| flex: 1 1 auto; | |
| min-height: 0; | |
| display: flex; | |
| flex-direction: column; | |
| position: relative; /* scopes the detail overlay to the section */ | |
| } | |
| /* ---------- Top bar ---------- */ | |
| #topbar, .topbar { | |
| display: flex; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 16px; | |
| padding: 10px 14px; | |
| background: var(--bg-elev); | |
| border-bottom: 1px solid var(--border); | |
| flex: 0 0 auto; | |
| } | |
| .bar-left { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; min-width: 0; } | |
| .bar-center { flex: 1 1 220px; display: flex; justify-content: center; min-width: 0; } | |
| .bar-right { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; } | |
| .folder-name { | |
| color: var(--text-dim); | |
| font-size: 13px; | |
| max-width: 220px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .filters { display: flex; gap: 4px; } | |
| .chip { | |
| background: transparent; | |
| border: 1px solid var(--border); | |
| color: var(--text-dim); | |
| padding: 6px 10px; | |
| font-size: 12px; | |
| border-radius: 999px; | |
| cursor: pointer; | |
| } | |
| .chip:hover { color: var(--text); } | |
| .chip.active { background: var(--accent-dim); border-color: var(--accent); color: #fff; } | |
| .counts { font-size: 12px; color: var(--text-dim); white-space: nowrap; } | |
| /* ---------- Grid ---------- */ | |
| .grid-view { flex: 1 1 auto; position: relative; min-height: 0; } | |
| .grid-scroll { | |
| position: absolute; | |
| inset: 0; | |
| overflow-y: auto; | |
| overflow-x: hidden; | |
| padding: 12px; | |
| } | |
| .grid-inner { position: relative; width: 100%; } | |
| .cell { | |
| position: absolute; | |
| background: var(--bg-cell); | |
| border: 1px solid var(--border); | |
| border-radius: var(--cell-radius); | |
| overflow: hidden; | |
| cursor: pointer; | |
| } | |
| .cell:hover { border-color: var(--accent); } | |
| .cell.dirty { box-shadow: inset 0 0 0 2px var(--dirty); } | |
| .cell .thumb { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| display: block; | |
| opacity: 0; | |
| transition: opacity 0.2s ease; | |
| background: | |
| repeating-conic-gradient(#20242c 0% 25%, #262b34 0% 50%) 50% / 20px 20px; | |
| } | |
| .cell .thumb.loaded { opacity: 1; } | |
| .badge { | |
| position: absolute; | |
| top: 6px; | |
| right: 6px; | |
| width: 22px; | |
| height: 22px; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 12px; | |
| font-weight: 700; | |
| color: #fff; | |
| border: 2px solid rgba(0,0,0,0.35); | |
| } | |
| .badge.has { background: var(--ok); } | |
| .badge.no { background: var(--none); } | |
| .empty-state { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 4px; | |
| text-align: center; | |
| pointer-events: none; | |
| } | |
| .empty-state .muted { color: var(--text-dim); font-size: 13px; } | |
| /* ---------- Detail ---------- */ | |
| .detail-view { | |
| position: absolute; | |
| inset: 0; | |
| top: 0; | |
| display: flex; | |
| flex-direction: column; | |
| background: var(--bg); | |
| z-index: 20; | |
| } | |
| .detail-top { | |
| display: flex; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| padding: 10px 14px; | |
| background: var(--bg-elev); | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .detail-title { | |
| font-size: 13px; | |
| color: var(--text); | |
| max-width: 40%; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .dirty-flag { color: var(--dirty); font-size: 12px; } | |
| .spacer { flex: 1 1 auto; } | |
| .detail-body { | |
| flex: 1 1 auto; | |
| display: grid; | |
| /* Prompt is the focus: it takes the large column on the left; the image | |
| sits in a narrow column on the right. */ | |
| grid-template-columns: 1fr 420px; | |
| grid-template-rows: minmax(0, 1fr); | |
| min-height: 0; | |
| } | |
| .detail-image { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 16px; | |
| background: #0e0f12; | |
| min-width: 0; | |
| min-height: 0; | |
| } | |
| /* Privacy: hide the original — the editor takes the full width. */ | |
| .detail-view.img-hidden .detail-body { grid-template-columns: 1fr 0; } | |
| .detail-view.img-hidden .detail-image { display: none; } | |
| .detail-image img { | |
| max-width: 100%; | |
| max-height: 100%; | |
| object-fit: contain; | |
| } | |
| .detail-edit { | |
| display: flex; | |
| flex-direction: column; | |
| border-right: 1px solid var(--border); | |
| min-width: 0; | |
| min-height: 0; | |
| } | |
| .prompt-editor { | |
| flex: 1 1 auto; | |
| resize: none; | |
| background: var(--bg-cell); | |
| color: var(--text); | |
| border: none; | |
| outline: none; | |
| padding: 16px; | |
| font-size: 14px; | |
| line-height: 1.5; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| } | |
| .detail-meta { | |
| flex: 0 0 auto; | |
| padding: 10px 16px; | |
| border-top: 1px solid var(--border); | |
| color: var(--text-dim); | |
| font-size: 12px; | |
| } | |
| /* ---------- Banner ---------- */ | |
| .banner { | |
| background: var(--error); | |
| color: #fff; | |
| padding: 10px 14px; | |
| font-size: 13px; | |
| } | |
| .banner code { background: rgba(0,0,0,0.25); padding: 1px 5px; border-radius: 4px; } | |
| /* ---------- Toasts ---------- */ | |
| .toasts { | |
| position: fixed; | |
| top: 16px; | |
| right: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| z-index: 300; | |
| max-width: 380px; | |
| } | |
| .toast { | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| border-left: 3px solid var(--accent); | |
| border-radius: 6px; | |
| padding: 10px 12px; | |
| font-size: 13px; | |
| box-shadow: 0 6px 20px rgba(0,0,0,0.4); | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| } | |
| .toast.warn { border-left-color: var(--warn); } | |
| .toast.error { border-left-color: var(--error); } | |
| .toast .toast-actions { display: flex; gap: 8px; justify-content: flex-end; } | |
| .toast .toast-actions .btn { padding: 5px 10px; font-size: 12px; } | |
| /* ---------- Modal (help / example) ---------- */ | |
| .modal { | |
| position: fixed; | |
| inset: 0; | |
| z-index: 200; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal-backdrop { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(0, 0, 0, 0.6); | |
| } | |
| .modal-box { | |
| position: relative; | |
| z-index: 1; | |
| width: min(860px, 92vw); | |
| max-height: 86vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| background: var(--bg-elev); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); | |
| } | |
| .modal-head { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 12px 14px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .modal-title { font-weight: 600; font-size: 14px; } | |
| .modal-body { | |
| flex: 1 1 auto; | |
| min-height: 0; | |
| overflow: auto; | |
| padding: 16px; | |
| color: var(--text); | |
| font-size: 13px; | |
| line-height: 1.55; | |
| } | |
| .modal-body.pre { | |
| white-space: pre-wrap; | |
| word-break: break-word; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| } | |
| .modal-html p { margin: 0 0 12px; } | |
| .modal-html p:last-child { margin-bottom: 0; } | |
| .modal-html .note { | |
| border-left: 3px solid var(--border); | |
| padding-left: 10px; | |
| color: var(--text-dim); | |
| } | |
| .modal-html h3 { | |
| margin: 22px 0 10px; | |
| padding-bottom: 6px; | |
| border-bottom: 1px solid var(--border); | |
| font-size: 15px; | |
| font-weight: 700; | |
| color: var(--text); | |
| } | |
| .modal-html h3:first-child { margin-top: 0; } | |
| .modal-html h4 { | |
| margin: 18px 0 8px; | |
| font-size: 13px; | |
| font-weight: 700; | |
| color: var(--text); | |
| } | |
| .modal-html h4:first-child { margin-top: 0; } | |
| .modal-html blockquote { | |
| margin: 0 0 12px; | |
| border-left: 3px solid var(--border); | |
| padding-left: 10px; | |
| color: var(--text-dim); | |
| } | |
| .modal-html details { margin: 0 0 12px; } | |
| .modal-html summary { cursor: pointer; color: var(--accent); margin-bottom: 8px; } | |
| .modal-html strong { color: var(--text); } | |
| .modal-html ul { margin: 0 0 12px; padding-left: 20px; } | |
| .modal-html li { margin: 0 0 5px; } | |
| .modal-html code { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: 4px; | |
| padding: 1px 5px; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| font-size: 12px; | |
| } | |
| .modal-html pre.code { | |
| background: var(--bg); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| padding: 10px 12px; | |
| margin: 0 0 12px; | |
| overflow-x: auto; | |
| white-space: pre-wrap; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| font-size: 12px; | |
| } | |
| .modal-html table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin: 0 0 12px; | |
| font-size: 12px; | |
| } | |
| .modal-html th, | |
| .modal-html td { | |
| border: 1px solid var(--border); | |
| padding: 6px 8px; | |
| text-align: left; | |
| vertical-align: top; | |
| } | |
| .modal-html th { background: var(--bg-cell); } | |
| /* AI settings button status dot: grey=unconfigured, amber=unreachable/checking, | |
| green=connected. */ | |
| #llmSettingsBtn { display: inline-flex; align-items: center; } | |
| .llm-dot { | |
| display: inline-block; | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| margin-right: 7px; | |
| background: var(--none); | |
| } | |
| .llm-dot-off { background: var(--none); } | |
| .llm-dot-warn { background: var(--warn); } | |
| .llm-dot-on { background: var(--ok); } | |
| .llm-dot-checking { background: var(--warn); animation: llm-dot-pulse 1s ease-in-out infinite; } | |
| @keyframes llm-dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } | |
| /* ---------- AI settings dialog ---------- */ | |
| .llm-form { display: flex; flex-direction: column; gap: 14px; max-width: 560px; } | |
| .llm-field { display: flex; flex-direction: column; gap: 5px; } | |
| .llm-label { | |
| font-size: 11px; | |
| color: var(--text); | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| } | |
| .llm-label .muted { text-transform: none; letter-spacing: 0; color: var(--text-dim); } | |
| .llm-input { | |
| width: 100%; | |
| background: var(--bg-cell); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text); | |
| padding: 7px 10px; | |
| font-size: 13px; | |
| outline: none; | |
| } | |
| .llm-input:focus { border-color: var(--accent); } | |
| .llm-input-narrow { width: 100px; } | |
| .llm-checkline { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 13px; | |
| color: var(--text); | |
| cursor: pointer; | |
| } | |
| .llm-checkline input { accent-color: var(--accent); cursor: pointer; } | |
| .llm-model-row { display: flex; gap: 8px; align-items: center; } | |
| .llm-model-row .llm-input { flex: 1 1 auto; } | |
| .llm-actions { display: flex; align-items: center; gap: 12px; margin-top: 4px; } | |
| .llm-reset { padding: 3px 9px; font-size: 11px; } | |
| /* ---------- Prompt-templates manager (full-screen, sidebar + pane) ---------- */ | |
| .modal-front { z-index: 210; } /* sits above the AI-settings dialog */ | |
| .modal-box-xl { width: min(1200px, 96vw); height: 90vh; max-height: 90vh; } | |
| .pt-body { display: flex; padding: 0; overflow: hidden; } | |
| .pt-layout { | |
| flex: 1 1 auto; | |
| min-height: 0; | |
| width: 100%; | |
| display: grid; | |
| grid-template-columns: 220px 1fr; | |
| } | |
| .pt-sidebar { | |
| border-right: 1px solid var(--border); | |
| background: var(--bg-elev); | |
| overflow-y: auto; | |
| min-height: 0; | |
| padding: 10px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| } | |
| .pt-sidebar-item { | |
| text-align: left; | |
| background: transparent; | |
| border: 1px solid transparent; | |
| color: var(--text-dim); | |
| padding: 8px 10px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| } | |
| .pt-sidebar-item:hover { color: var(--text); } | |
| .pt-sidebar-item.active { | |
| background: var(--bg-cell); | |
| border-color: var(--border); | |
| color: var(--text); | |
| } | |
| .pt-pane { | |
| overflow-y: auto; | |
| min-height: 0; | |
| padding: 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 18px; | |
| } | |
| .pt-area-title { | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| color: var(--text); | |
| font-weight: 600; | |
| margin: 14px 6px 4px; | |
| } | |
| .pt-area-title:first-child { margin-top: 0; } | |
| .pt-type-title { | |
| font-size: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| color: var(--text-dim); | |
| margin: 6px 6px 2px 14px; | |
| } | |
| .pt-sidebar-item.pt-nested { margin-left: 10px; } | |
| .pt-purpose-title { font-weight: 600; font-size: 15px; } | |
| .pt-tpl-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding-bottom: 12px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .pt-tpl-bar .llm-label { flex: 0 0 auto; } | |
| .pt-tpl-bar select { flex: 1 1 auto; } | |
| .pt-field { display: flex; flex-direction: column; gap: 5px; } | |
| .pt-field-head { display: flex; align-items: center; gap: 10px; } | |
| .pt-field-head .llm-label { flex: 1 1 auto; } | |
| .pt-textarea { min-height: 160px; } | |
| .llm-status { font-size: 12px; color: var(--text-dim); } | |
| .llm-status.ok { color: var(--accent); } | |
| .llm-status.warn { color: var(--warn); } | |
| .llm-status.error { color: var(--error); } | |
| /* ---------- Generate-with-AI dialog (image prompts) ---------- */ | |
| /* Roomy modal: it can hold a lot of generated text, so give it width + height | |
| and let the result area grow to fill whatever space is left. */ | |
| .modal-box-lg { width: min(1100px, 95vw); height: 88vh; max-height: 88vh; } | |
| .modal-box-lg .modal-body { display: flex; flex-direction: column; } | |
| .ai-gen { display: flex; flex-direction: column; gap: 14px; flex: 1 1 auto; min-height: 0; } | |
| .llm-field-grow { flex: 1 1 auto; min-height: 0; } | |
| .llm-field-grow .ai-gen-ta { flex: 1 1 auto; height: 100%; min-height: 160px; resize: none; } | |
| .ai-gen-cols { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; } | |
| .ai-gen-image { | |
| flex: 0 0 200px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| } | |
| .ai-gen-image img { | |
| width: 100%; | |
| max-height: 240px; | |
| object-fit: contain; | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| background: var(--bg-cell); | |
| } | |
| .ai-gen-image .btn { width: 100%; } | |
| .ai-gen-image .muted { font-size: 11px; word-break: break-all; } | |
| .ai-gen-imgnote { font-style: italic; } | |
| .ai-gen-fields { flex: 1 1 320px; display: flex; flex-direction: column; gap: 12px; min-width: 0; } | |
| .ai-gen-ta { | |
| width: 100%; | |
| box-sizing: border-box; | |
| background: var(--bg-cell); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text); | |
| padding: 8px 10px; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| resize: vertical; | |
| outline: none; | |
| } | |
| .ai-gen-ta:focus { border-color: var(--accent); } | |
| .ai-gen-ta[readonly] { opacity: 0.8; cursor: default; } | |
| .ai-gen-result { min-height: 140px; } | |
| .ai-gen-actions { display: flex; align-items: center; gap: 12px; } | |
| /* ---------- Prompt library ---------- */ | |
| .lib-body { | |
| flex: 1 1 auto; | |
| min-height: 0; | |
| position: relative; | |
| display: grid; | |
| grid-template-columns: 300px 1fr; | |
| /* Fill the container height so the editor (and its textarea) can grow to the | |
| full available height instead of collapsing to content height. */ | |
| grid-template-rows: minmax(0, 1fr); | |
| } | |
| .lib-list { | |
| overflow-y: auto; | |
| min-height: 0; | |
| border-right: 1px solid var(--border); | |
| background: var(--bg-elev); | |
| } | |
| /* Collapsed master list — the editor takes the full width. The track count must | |
| drop to ONE: `display:none` takes the list out of grid flow, so with two | |
| tracks the editor would auto-place into the (now 0-wide) first column. */ | |
| .lib-body.list-collapsed { grid-template-columns: minmax(0, 1fr); } | |
| .lib-body.list-collapsed .lib-list { display: none; } | |
| /* Compact glyph button that collapses/expands the list. */ | |
| .lib-list-toggle { padding: 7px 9px; } | |
| .lib-item { | |
| padding: 10px 12px; | |
| border-bottom: 1px solid var(--border); | |
| cursor: pointer; | |
| } | |
| .lib-item:hover { background: var(--bg-cell); } | |
| .lib-item.active { background: var(--accent-dim); } | |
| .lib-item .t { | |
| font-size: 13px; | |
| color: var(--text); | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .lib-item .tags { | |
| font-size: 11px; | |
| color: var(--text-dim); | |
| margin-top: 3px; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| white-space: nowrap; | |
| } | |
| .lib-right { | |
| position: relative; | |
| min-width: 0; | |
| min-height: 0; | |
| display: flex; | |
| } | |
| .lib-editor { | |
| flex: 1 1 auto; | |
| display: flex; | |
| flex-direction: column; | |
| min-width: 0; | |
| } | |
| .lib-empty { | |
| flex: 1 1 auto; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 4px; | |
| text-align: center; | |
| padding: 24px; | |
| } | |
| .lib-empty .muted { color: var(--text-dim); font-size: 13px; } | |
| .lib-empty code { background: rgba(0,0,0,0.25); padding: 1px 5px; border-radius: 4px; } | |
| .lib-edit-top { | |
| display: flex; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| padding: 10px 12px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .lib-title-input { | |
| /* Shrinks before anything else, so the type, stars and actions to its right | |
| stay on screen; below ~900px it takes a row of its own (see the end). */ | |
| flex: 1 1 220px; | |
| min-width: 0; | |
| background: var(--bg-cell); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text); | |
| padding: 7px 10px; | |
| font-size: 14px; | |
| outline: none; | |
| } | |
| .lib-title-input:focus { border-color: var(--accent); } | |
| /* Prompt-type selector in the editor header. */ | |
| .lib-type-select { | |
| flex: 0 0 auto; | |
| background: var(--bg-cell); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text); | |
| padding: 6px 8px; | |
| font-size: 12px; | |
| outline: none; | |
| } | |
| .lib-type-select:focus { border-color: var(--accent); } | |
| /* Plain-text body editor for the "default" prompt type. */ | |
| .lib-body-edit { padding: 12px; } | |
| .lib-body-text { | |
| width: 100%; | |
| box-sizing: border-box; | |
| min-height: 240px; | |
| background: var(--bg-cell); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| color: var(--text); | |
| padding: 10px 12px; | |
| font-size: 13px; | |
| line-height: 1.55; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| resize: none; | |
| overflow: hidden; /* height managed by auto-grow */ | |
| outline: none; | |
| } | |
| .lib-body-text:focus { border-color: var(--accent); } | |
| /* Header row above the global/override sections (holds the ✨ Generate button). */ | |
| .lib-global-head { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: flex-start; | |
| gap: 8px; | |
| padding: 10px 12px 0; | |
| } | |
| .lib-global-title { | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| color: var(--text); | |
| font-weight: 600; | |
| } | |
| /* Subject-definitions builder row in the globals generator. */ | |
| .gen-subj-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } | |
| .gen-subj-row .llm-input { width: auto; flex: 0 0 auto; } | |
| .gen-next-label { | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| font-size: 13px; | |
| color: var(--accent); | |
| } | |
| .gen-file { font-size: 12px; color: var(--text-dim); max-width: 220px; } | |
| .gen-help-row { display: flex; align-items: center; gap: 6px; } | |
| /* Reference list in the globals generator (source of truth = the textareas). */ | |
| .gen-list { display: flex; flex-direction: column; gap: 4px; } | |
| .gen-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 4px 6px; | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| background: var(--bg-cell); | |
| } | |
| .gen-item-btn { padding: 2px 8px; font-size: 12px; line-height: 1.2; } | |
| .gen-item-label { | |
| flex: 0 0 auto; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| font-size: 12px; | |
| color: var(--accent); | |
| } | |
| .gen-item-preview { | |
| flex: 1 1 auto; | |
| min-width: 0; | |
| font-size: 12px; | |
| color: var(--text-dim); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| /* ---------- Rating (prompt metadata) ---------- */ | |
| .rating { display: inline-flex; gap: 1px; } | |
| .rating .star { | |
| background: none; | |
| border: none; | |
| padding: 0 2px; | |
| cursor: pointer; | |
| line-height: 1; | |
| font-size: 18px; | |
| color: var(--text-dim); | |
| } | |
| .rating .star.on { color: var(--star); } | |
| .rating:hover .star { color: var(--text-dim); } | |
| .rating:hover .star.hot { color: var(--star); } | |
| .lib-item .stars { color: var(--star); } | |
| /* Scrollable editor body (global field + scenes share one scroll region) */ | |
| .lib-scroll { | |
| flex: 1 1 auto; | |
| min-height: 0; | |
| overflow-y: auto; | |
| } | |
| .lib-global { | |
| padding: 12px 12px 0; | |
| } | |
| /* Prompt-level metadata block (loras, …) above the global sections. */ | |
| .lib-meta { | |
| padding: 12px 12px 0; | |
| border-bottom: 1px solid var(--border); | |
| padding-bottom: 12px; | |
| } | |
| .lib-meta .scene-label-row { margin-top: 0; } | |
| .lib-meta label { | |
| font-size: 11px; | |
| color: var(--text); | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| } | |
| .lib-loras-input { | |
| width: 100%; | |
| background: var(--bg-cell); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| color: var(--text); | |
| padding: 7px 10px; | |
| font-size: 13px; | |
| outline: none; | |
| } | |
| .lib-loras-input:focus { border-color: var(--accent); } | |
| .global-tag { | |
| margin-left: 8px; | |
| font-size: 10px; | |
| color: var(--accent); | |
| border: 1px solid var(--accent-dim); | |
| border-radius: 999px; | |
| padding: 1px 7px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| vertical-align: middle; | |
| } | |
| /* Scenes list + cards */ | |
| .lib-scenes { | |
| padding: 12px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 14px; | |
| } | |
| .lib-scenes-footer { | |
| flex: 0 0 auto; | |
| display: flex; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 14px; | |
| padding: 10px 12px; | |
| border-top: 1px solid var(--border); | |
| } | |
| .scene-card { | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| background: var(--bg-elev); | |
| } | |
| .scene-head { | |
| display: flex; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| padding: 8px 10px; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .scene-card.collapsed .scene-head { border-bottom: none; } | |
| .scene-card.collapsed .scene-body { display: none; } | |
| .scene-toggle { | |
| width: 20px; | |
| height: 20px; | |
| padding: 0; | |
| line-height: 1; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-dim); | |
| font-size: 11px; | |
| cursor: pointer; | |
| } | |
| .scene-toggle:hover { color: var(--text); } | |
| .scene-title { font-weight: 600; font-size: 13px; cursor: pointer; user-select: none; } | |
| /* Compact reorder arrows in the scene header. */ | |
| .scene-move { padding: 7px 9px; font-size: 12px; } | |
| /* "Incomplete" badge + card accent for scenes missing a required section. */ | |
| .scene-status { | |
| font-size: 10px; | |
| color: var(--warn); | |
| border: 1px solid var(--warn); | |
| border-radius: 999px; | |
| padding: 1px 7px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| white-space: nowrap; | |
| } | |
| .scene-card.incomplete { border-color: var(--warn); } | |
| .lib-incomplete { color: var(--warn); } | |
| .scene-body { padding: 10px 12px 12px; } | |
| /* Optional per-scene overrides (subject_definitions, retention_analysis), | |
| hidden by default behind a toggle to save space. */ | |
| .scene-overrides { | |
| margin-bottom: 12px; | |
| border: 1px dashed var(--border); | |
| border-radius: 6px; | |
| background: var(--bg-cell); | |
| } | |
| .scene-overrides-toggle { | |
| display: block; | |
| width: 100%; | |
| text-align: left; | |
| padding: 7px 10px; | |
| border: none; | |
| background: transparent; | |
| color: var(--text-dim); | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| cursor: pointer; | |
| } | |
| .scene-overrides-toggle:hover { color: var(--text); } | |
| .scene-overrides-body { padding: 0 10px 10px; } | |
| .scene-override-gen { margin: 4px 0 10px; } | |
| .scene-overrides.collapsed { border-style: solid; } | |
| .scene-overrides.collapsed .scene-overrides-body { display: none; } | |
| /* A scene that actually overrides a global section is marked in green, so a | |
| collapsed group still tells you an override is applied (and to which | |
| sections) without expanding it. */ | |
| .scene-overrides.has-override { border-color: var(--ok-dim); } | |
| .scene-overrides.has-override .scene-overrides-toggle { color: var(--ok); } | |
| .scene-overrides.has-override.collapsed { background: var(--ok-dim); } | |
| .scene-overrides.has-override.collapsed .scene-overrides-toggle { color: var(--text); } | |
| /* Section names inside the label keep their on-disk lowercase spelling. */ | |
| .scene-overrides-names { | |
| text-transform: none; | |
| letter-spacing: 0; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| } | |
| .scene-overrides-body > .scene-label-row:first-child { margin-top: 4px; } | |
| /* The override group supplies the gap below it, so the next label sits tight. */ | |
| .scene-overrides + .scene-label-row { margin-top: 0; } | |
| /* Task-type checkboxes below the summary label, above the field */ | |
| .task-types { margin: 2px 0 6px; } | |
| .task-types-grid { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px 16px; | |
| } | |
| .task-type { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 12px; | |
| color: var(--text-dim); | |
| cursor: pointer; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| } | |
| .task-type input { accent-color: var(--accent); cursor: pointer; } | |
| .scene-label-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| margin: 12px 0 2px; | |
| } | |
| .scene-body > .scene-label-row:first-child { margin-top: 0; } | |
| .scene-body label { | |
| font-size: 11px; | |
| color: var(--text); | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| } | |
| .section-help { | |
| width: 18px; | |
| height: 18px; | |
| padding: 0; | |
| line-height: 1; | |
| border-radius: 50%; | |
| border: 1px solid var(--border); | |
| background: var(--bg-cell); | |
| color: var(--text-dim); | |
| font-size: 11px; | |
| font-weight: 700; | |
| cursor: pointer; | |
| } | |
| .section-help:hover { color: var(--text); border-color: var(--accent); } | |
| .scene-hint { | |
| margin: 0 0 6px; | |
| font-size: 11px; | |
| font-style: italic; | |
| color: var(--text-dim); | |
| } | |
| /* Live word count on the detailed_description label row (pushed to the right). */ | |
| .word-count { | |
| margin-left: auto; | |
| font-size: 11px; | |
| color: var(--text-dim); | |
| font-variant-numeric: tabular-nums; | |
| white-space: nowrap; | |
| } | |
| /* Highlight overlay: backdrop <div> behind a transparent <textarea>. | |
| Both must share identical box metrics so tokens line up exactly. */ | |
| .hl-wrap { position: relative; } | |
| .hl-backdrop, | |
| .scene-section { | |
| box-sizing: border-box; | |
| width: 100%; | |
| margin: 0; | |
| padding: 8px 10px; | |
| border: 1px solid transparent; | |
| border-radius: 6px; | |
| font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| white-space: pre-wrap; | |
| overflow-wrap: break-word; | |
| } | |
| .hl-backdrop { | |
| position: absolute; | |
| inset: 0; | |
| z-index: 0; | |
| overflow: hidden; | |
| background: var(--bg-cell); | |
| color: transparent; /* only the <mark> backgrounds show through */ | |
| pointer-events: none; | |
| user-select: none; | |
| } | |
| .hl-backdrop mark { | |
| margin: 0; | |
| padding: 0; /* padding would shift following glyphs out of alignment */ | |
| color: transparent; | |
| border-radius: 3px; | |
| background: transparent; | |
| } | |
| .hl-backdrop mark.hl-shot { background: rgba(55, 192, 122, 0.30); } /* [Shot N] */ | |
| .hl-backdrop mark.hl-bracket { background: rgba(224, 161, 58, 0.32); } /* [task type] */ | |
| .hl-backdrop mark.hl-angle { background: rgba(79, 140, 255, 0.32); } /* <Subject N> */ | |
| .scene-section { | |
| display: block; | |
| position: relative; | |
| z-index: 1; | |
| border-color: var(--border); | |
| background: transparent; /* let the backdrop show behind */ | |
| color: var(--text); | |
| caret-color: var(--text); | |
| resize: none; | |
| overflow: hidden; /* height is managed by auto-grow */ | |
| min-height: 40px; | |
| } | |
| .scene-section:focus { border-color: var(--accent); outline: none; } | |
| /* Two-mode empty state for the library pane. */ | |
| #libEmpty .lib-empty-select { display: none; } | |
| #libEmpty[data-mode="select"] .lib-empty-nofolder { display: none; } | |
| #libEmpty[data-mode="select"] .lib-empty-select { display: block; } | |
| /* ---------- Narrow / portrait windows ---------- | |
| This is a desktop tool, but in a small or 9:16 window the fixed two-column | |
| layouts leave one side with (almost) no width and the toolbars run off the | |
| edge. Every bar above wraps; here the columns give way instead. */ | |
| @media (max-width: 900px) { | |
| /* Prompt editor header: the title takes its own row so the type selector, | |
| stars and action buttons below it are always reachable. */ | |
| .lib-title-input { flex: 1 1 100%; } | |
| /* Master list: proportional instead of a fixed 300px, so the editor keeps the | |
| larger share. "⟨" / Ctrl+B still hides it entirely. */ | |
| .lib-body { grid-template-columns: minmax(140px, 24%) minmax(0, 1fr); } | |
| /* Image detail: stack the prompt over the image — a fixed 420px image column | |
| would squeeze the editor to nothing. */ | |
| .detail-body { | |
| grid-template-columns: minmax(0, 1fr); | |
| grid-template-rows: minmax(0, 1fr) 35vh; | |
| } | |
| .detail-edit { border-right: none; border-bottom: 1px solid var(--border); } | |
| /* Image hidden: reclaim the row it left behind (the columns rule above the | |
| .img-hidden one still applies, it just has one visible child). */ | |
| .detail-view.img-hidden .detail-body { grid-template-rows: minmax(0, 1fr); } | |
| .detail-title { max-width: 100%; } | |
| /* Prompt-templates modal: same treatment for its fixed sidebar. */ | |
| .pt-layout { grid-template-columns: minmax(120px, 30%) minmax(0, 1fr); } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Unsupported-browser banner --> | |
| <div id="unsupported" class="banner" hidden> | |
| This app needs the <strong>File System Access API</strong>. Please use a recent | |
| version of <strong>Chrome, Edge or Opera</strong>, served over <code>localhost</code>. | |
| </div> | |
| <!-- Top-level section switch --> | |
| <header id="appnav"> | |
| <div class="brand">aiprompt-marie-kondo</div> | |
| <nav class="sections" aria-label="Sections"> | |
| <button class="tab active" data-section="images">Image prompts</button> | |
| <button class="tab" data-section="library">Prompt library</button> | |
| </nav> | |
| <span class="spacer"></span> | |
| <button id="askAiBtn" class="btn" title="Ask the LLM anything (scratchpad)">💬 Ask AI</button> | |
| <button id="llmSettingsBtn" class="btn" title="Configure the AI / LLM connection used across the app"> | |
| <span id="llmStatusDot" class="llm-dot llm-dot-off"></span>AI settings | |
| </button> | |
| </header> | |
| <!-- ================= IMAGE PROMPTS SECTION ================= --> | |
| <div id="imagesSection" class="section"> | |
| <header id="topbar" class="topbar"> | |
| <div class="bar-left"> | |
| <button id="openBtn" class="btn primary">Open folder…</button> | |
| <button id="reopenBtn" class="btn" hidden></button> | |
| <span id="folderName" class="folder-name"></span> | |
| </div> | |
| <div class="bar-center"> | |
| <input id="search" class="search" type="text" | |
| placeholder="Search prompts… (Ctrl+F)" autocomplete="off" spellcheck="false" /> | |
| </div> | |
| <div class="bar-right"> | |
| <div class="filters" role="group" aria-label="Filter"> | |
| <button class="chip active" data-filter="all">All</button> | |
| <button class="chip" data-filter="with">With prompt</button> | |
| <button class="chip" data-filter="without">No prompt</button> | |
| </div> | |
| <span id="counts" class="counts"></span> | |
| </div> | |
| </header> | |
| <!-- GRID VIEW --> | |
| <main id="gridView" class="grid-view"> | |
| <div id="grid" class="grid-scroll"> | |
| <div id="gridInner" class="grid-inner"></div> | |
| </div> | |
| <div id="empty" class="empty-state"> | |
| <p>Open a folder of images to begin.</p> | |
| <p class="muted">Every image is shown with a badge telling you whether it has a prompt.</p> | |
| </div> | |
| </main> | |
| <!-- DETAIL VIEW --> | |
| <section id="detailView" class="detail-view" hidden> | |
| <div class="detail-top"> | |
| <button id="backBtn" class="btn">← Back</button> | |
| <span id="detailTitle" class="detail-title"></span> | |
| <span id="dirtyFlag" class="dirty-flag" hidden>● Unsaved</span> | |
| <span class="spacer"></span> | |
| <button id="toggleImgBtn" class="btn" title="Hide the image for privacy">Hide image ⟩</button> | |
| <button id="renameBtn" class="btn" title="Rename image (and its .txt)">Rename</button> | |
| <button id="deleteBtn" class="btn danger" title="Delete image (and its .txt) from disk">Delete</button> | |
| <button id="prevBtn" class="btn" title="Previous (←)">←</button> | |
| <button id="nextBtn" class="btn" title="Next (→)">→</button> | |
| <button id="aiGenBtn" class="btn" title="Generate the prompt from the image with AI" disabled>✨ Generate</button> | |
| <button id="copyBtn" class="btn" title="Copy prompt (Ctrl+C)">Copy</button> | |
| <button id="saveBtn" class="btn primary" title="Save (Ctrl+Enter) — stays on this image">Save ⏎</button> | |
| </div> | |
| <div class="detail-body"> | |
| <div class="detail-edit"> | |
| <textarea id="promptEditor" class="prompt-editor" | |
| placeholder="No prompt yet — write one and press Ctrl+Enter to save." | |
| spellcheck="false"></textarea> | |
| <div id="detailMeta" class="detail-meta"></div> | |
| </div> | |
| <div class="detail-image"> | |
| <img id="detailImg" alt="" /> | |
| </div> | |
| </div> | |
| </section> | |
| </div><!-- /#imagesSection --> | |
| <!-- ================= PROMPT LIBRARY SECTION ================= --> | |
| <div id="librarySection" class="section" hidden> | |
| <header id="libToolbar" class="topbar"> | |
| <div class="bar-left"> | |
| <button id="libListToggle" class="btn lib-list-toggle" type="button" | |
| aria-controls="libList" aria-expanded="true">⟨</button> | |
| <button id="libOpenBtn" class="btn primary">Open library…</button> | |
| <button id="libReopenBtn" class="btn" hidden></button> | |
| <span id="libFolderName" class="folder-name"></span> | |
| </div> | |
| <div class="bar-center"> | |
| <input id="libSearch" class="search" type="text" | |
| placeholder="Search prompts, tags… (Ctrl+F)" autocomplete="off" spellcheck="false" /> | |
| </div> | |
| <div class="bar-right"> | |
| <button id="libGuideBtn" class="btn" title="Show the full prompt guide">Prompt guide</button> | |
| <button id="libHelpBtn" class="btn" title="Show a full example prompt">Prompt example</button> | |
| <button id="libNewBtn" class="btn" disabled>+ New prompt</button> | |
| <span id="libCounts" class="counts"></span> | |
| </div> | |
| </header> | |
| <div id="libLayout" class="lib-body"> | |
| <div id="libList" class="lib-list"></div> | |
| <div class="lib-right"> | |
| <div id="libEditor" class="lib-editor" hidden> | |
| <div class="lib-edit-top"> | |
| <input id="libTitle" class="lib-title-input" type="text" | |
| placeholder="prompt-title" autocomplete="off" spellcheck="false" /> | |
| <select id="libType" class="lib-type-select" title="Prompt type"></select> | |
| <div id="libRating" class="rating" role="radiogroup" aria-label="Rating (1–5)"></div> | |
| <span id="libDirty" class="dirty-flag" hidden>● Unsaved</span> | |
| <span class="spacer"></span> | |
| <button id="libSaveBtn" class="btn primary" title="Save (Ctrl+S)">Save</button> | |
| <!-- Everything but Save lives behind "⋮" so the header stays one row. --> | |
| <div id="libMenu" class="action-menu"> | |
| <button id="libMenuBtn" class="btn action-menu-btn" type="button" | |
| title="More actions" aria-haspopup="menu" aria-expanded="false">⋮</button> | |
| <div class="action-menu-list" role="menu" hidden> | |
| <button id="libReviewBtn" class="action-menu-item scene-generate" type="button" | |
| role="menuitem" disabled hidden>Review with AI</button> | |
| <button id="libPreviewBtn" class="action-menu-item" type="button" role="menuitem" | |
| title="Preview the whole prompt (and copy it)">Preview</button> | |
| <button id="libDuplicateBtn" class="action-menu-item" type="button" role="menuitem" | |
| title="Duplicate this prompt as a new one">Duplicate</button> | |
| <button id="libDeleteBtn" class="action-menu-item danger" type="button" role="menuitem" | |
| title="Delete this prompt file">Delete prompt</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="libScroll" class="lib-scroll"> | |
| <div class="lib-meta"> | |
| <div class="scene-label-row"><label for="libLorasInput">loras</label></div> | |
| <p class="scene-hint">Comma-separated LoRAs that can help this prompt. Shown here; never copied.</p> | |
| <input id="libLorasInput" class="lib-loras-input" type="text" | |
| placeholder="e.g. add_detail, film_grain, neon_style" | |
| autocomplete="off" spellcheck="false" /> | |
| </div> | |
| <div id="libBody" class="lib-body-edit" hidden> | |
| <div class="scene-label-row"> | |
| <label for="libBodyText">prompt</label> | |
| <span class="spacer"></span> | |
| <button id="libBodyGenBtn" class="btn scene-generate" type="button" disabled>✨ Generate</button> | |
| </div> | |
| <p class="scene-hint">The full prompt as plain text.</p> | |
| <textarea id="libBodyText" class="lib-body-text" spellcheck="false" | |
| placeholder="Write the full prompt here…"></textarea> | |
| </div> | |
| <div id="libGlobalHead" class="lib-global-head" hidden> | |
| <span class="lib-global-title">Global</span> | |
| <button id="libGlobalGenBtn" class="btn scene-generate" type="button" disabled>✨ Generate</button> | |
| </div> | |
| <div id="libGlobal" class="lib-global"></div> | |
| <div id="libScenes" class="lib-scenes"></div> | |
| </div> | |
| <div id="libScenesFooter" class="lib-scenes-footer"> | |
| <button id="libAddSceneBtn" class="btn">+ Add scene</button> | |
| <button id="libCollapseAllBtn" class="btn">Collapse all</button> | |
| <div id="libEditMeta" class="detail-meta"></div> | |
| </div> | |
| </div> | |
| <div id="libEmpty" class="lib-empty" data-mode="nofolder"> | |
| <p class="lib-empty-nofolder">Open a folder of prompt <code>.txt</code> files to begin.</p> | |
| <p class="lib-empty-select">Select a prompt on the left, or create a new one.</p> | |
| <p class="muted">Each file is one reusable prompt; the filename is its title.</p> | |
| </div> | |
| </div><!-- /.lib-right --> | |
| </div> | |
| </div><!-- /#librarySection --> | |
| <!-- Image-prompts: generate a prompt from the open image with a vision LLM --> | |
| <div id="aiGenModal" class="modal" hidden> | |
| <div class="modal-backdrop"></div> | |
| <div class="modal-box modal-box-lg"> | |
| <div class="modal-head"> | |
| <span class="modal-title">Generate prompt with AI</span> | |
| <span class="spacer"></span> | |
| <button id="aiGenCloseBtn" class="btn">Close</button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="ai-gen"> | |
| <div class="ai-gen-cols"> | |
| <div class="ai-gen-image"> | |
| <button id="aiGenToggleImgBtn" class="btn" type="button" | |
| title="Show/hide the image preview (it is sent to the AI either way)">Show image</button> | |
| <img id="aiGenImg" alt="" hidden /> | |
| <div id="aiGenImgName" class="muted"></div> | |
| <div class="ai-gen-imgnote muted">Hidden by default. Still sent to the AI.</div> | |
| </div> | |
| <div class="ai-gen-fields"> | |
| <label class="llm-field"> | |
| <span class="llm-label">Template</span> | |
| <select id="aiGenTemplate" class="llm-input"></select> | |
| </label> | |
| <label class="llm-field"> | |
| <span class="llm-label">System prompt</span> | |
| <textarea id="aiGenSystem" class="ai-gen-ta" rows="4" spellcheck="false"></textarea> | |
| </label> | |
| <label class="llm-field"> | |
| <span class="llm-label">User prompt</span> | |
| <textarea id="aiGenUser" class="ai-gen-ta" rows="2" spellcheck="false"></textarea> | |
| </label> | |
| <div class="ai-gen-actions"> | |
| <button id="aiGenRunBtn" class="btn primary" type="button">Generate</button> | |
| <button id="aiGenRefineBtn" class="btn" type="button" hidden | |
| title="Iterate: sends your user prompt as a follow-up, keeping the conversation and image context">Refine</button> | |
| <button id="aiGenStopBtn" class="btn" type="button" hidden>Stop</button> | |
| <span id="aiGenStatus" class="llm-status"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <label class="llm-field llm-field-grow"> | |
| <span class="llm-label">Result <span class="muted">(editable)</span></span> | |
| <textarea id="aiGenResult" class="ai-gen-ta ai-gen-result" rows="8" | |
| spellcheck="false" placeholder="The generated prompt will appear here…"></textarea> | |
| </label> | |
| <div class="ai-gen-actions"> | |
| <span class="spacer"></span> | |
| <button id="aiGenAcceptBtn" class="btn primary" type="button" disabled>Use as prompt</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Prompt library: generate MiniMax H3 globals (subject_definitions, retention) --> | |
| <div id="globalsGenModal" class="modal" hidden> | |
| <div class="modal-backdrop"></div> | |
| <div class="modal-box modal-box-lg"> | |
| <div class="modal-head"> | |
| <span class="modal-title">Generate globals with AI</span> | |
| <span class="spacer"></span> | |
| <button id="globalsGenCloseBtn" class="btn">Close</button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="ai-gen"> | |
| <div class="pt-purpose-title">Subject definitions</div> | |
| <label class="llm-field"> | |
| <span class="llm-label">Template</span> | |
| <select id="globalsTemplate" class="llm-input"></select> | |
| </label> | |
| <p class="scene-hint"> | |
| Pick a label type, a relationship marker and a reference image from disk. | |
| The model writes the definition line, and a matching retention_analysis | |
| entry is added for you to complete. Indices auto-increment per type. | |
| </p> | |
| <div class="gen-subj-row"> | |
| <select id="globalsLabelType" class="llm-input"> | |
| <option value="Subject">Subject</option> | |
| <option value="Picture">Picture</option> | |
| </select> | |
| <span id="globalsNextLabel" class="gen-next-label"></span> | |
| <select id="globalsMarker" class="llm-input" title="Retention relationship marker"></select> | |
| <input id="globalsImageInput" type="file" accept="image/*" class="gen-file" /> | |
| <button id="globalsAddBtn" class="btn primary" type="button" disabled>Add & generate</button> | |
| <button id="globalsStopBtn" class="btn" type="button" hidden>Stop</button> | |
| <span id="globalsStatus" class="llm-status"></span> | |
| </div> | |
| <div id="globalsList" class="gen-list"></div> | |
| <p id="globalsListEmpty" class="scene-hint">No references yet — add one above.</p> | |
| <div class="llm-field"> | |
| <div class="gen-help-row"> | |
| <span class="llm-label">subject_definitions <span class="muted">(editable)</span></span> | |
| <button id="globalsSubjHelp" class="section-help" type="button" | |
| title="About the "subject_definitions" section">?</button> | |
| </div> | |
| <textarea id="globalsSubjects" class="ai-gen-ta" rows="7" spellcheck="false" | |
| placeholder="Generated <Subject N> / <Picture N> lines appear here…"></textarea> | |
| </div> | |
| <div class="pt-purpose-title">Retention analysis</div> | |
| <p class="scene-hint">Added automatically per reference (no AI). Fill in the shots and replace each <code>TODO</code>.</p> | |
| <div class="llm-field"> | |
| <div class="gen-help-row"> | |
| <span class="llm-label">retention_analysis <span class="muted">(editable)</span></span> | |
| <button id="globalsRetHelp" class="section-help" type="button" | |
| title="About the "retention_analysis" section">?</button> | |
| </div> | |
| <textarea id="globalsRetention" class="ai-gen-ta" rows="6" spellcheck="false" | |
| placeholder="<Subject 1> (appears in [Shot 1]): fully_preserved - TODO"></textarea> | |
| </div> | |
| <div class="ai-gen-actions"> | |
| <span class="spacer"></span> | |
| <button id="globalsAcceptBtn" class="btn primary" type="button">Done</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Prompt library: generic text generator (scene, default prompt, …) --> | |
| <div id="textGenModal" class="modal" hidden> | |
| <div class="modal-backdrop"></div> | |
| <div class="modal-box modal-box-lg"> | |
| <div class="modal-head"> | |
| <span id="textGenTitle" class="modal-title">Generate with AI</span> | |
| <span class="spacer"></span> | |
| <button id="textGenCloseBtn" class="btn">Close</button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="ai-gen"> | |
| <label class="llm-field"> | |
| <span class="llm-label">Template</span> | |
| <select id="textGenTemplate" class="llm-input"></select> | |
| </label> | |
| <label class="llm-field"> | |
| <span class="llm-label">System prompt</span> | |
| <textarea id="textGenSystem" class="ai-gen-ta" rows="4" spellcheck="false"></textarea> | |
| </label> | |
| <label class="llm-field"> | |
| <span class="llm-label">User prompt</span> | |
| <textarea id="textGenUser" class="ai-gen-ta" rows="3" spellcheck="false"></textarea> | |
| </label> | |
| <label id="textGenContWrap" class="llm-checkline" hidden> | |
| <input id="textGenCont" type="checkbox" /> | |
| <span id="textGenContLabel">Continue from the previous scene</span> | |
| </label> | |
| <div id="textGenRefWrap" class="llm-field" hidden> | |
| <span id="textGenRefLabel" class="llm-label">subject_definitions (applied)</span> | |
| <textarea id="textGenRef" class="ai-gen-ta" rows="4" readonly | |
| title="Sent to the model as context (read-only). Edit it in the Global / override sections."></textarea> | |
| </div> | |
| <p id="textGenCtxNote" class="scene-hint"></p> | |
| <div class="ai-gen-actions"> | |
| <button id="textGenRunBtn" class="btn primary" type="button">Generate</button> | |
| <button id="textGenRefineBtn" class="btn" type="button" hidden | |
| title="Iterate on the result: sends your user prompt as a follow-up, keeping the conversation">Refine</button> | |
| <button id="textGenStopBtn" class="btn" type="button" hidden>Stop</button> | |
| <span id="textGenStatus" class="llm-status"></span> | |
| </div> | |
| <label class="llm-field llm-field-grow"> | |
| <span class="llm-label">Result <span id="textGenResultHint" class="muted">(editable)</span></span> | |
| <textarea id="textGenResult" class="ai-gen-ta ai-gen-result" rows="10" | |
| spellcheck="false" placeholder="The generated text will appear here…"></textarea> | |
| </label> | |
| <div class="ai-gen-actions"> | |
| <span class="spacer"></span> | |
| <button id="textGenAcceptBtn" class="btn primary" type="button" disabled>Use</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Full-screen prompt-templates manager (sidebar of groups + editor pane) --> | |
| <div id="promptTemplatesModal" class="modal modal-front" hidden> | |
| <div class="modal-backdrop"></div> | |
| <div class="modal-box modal-box-xl"> | |
| <div class="modal-head"> | |
| <span class="modal-title">Prompt templates</span> | |
| <span class="spacer"></span> | |
| <button id="ptSaveBtn" class="btn primary">Save</button> | |
| <button id="ptCloseBtn" class="btn">Close</button> | |
| </div> | |
| <div class="modal-body pt-body"> | |
| <div class="pt-layout"> | |
| <div id="ptSidebar" class="pt-sidebar"></div> | |
| <div id="ptPane" class="pt-pane"></div> | |
| </div> | |
| <datalist id="ptModelList"></datalist> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Ask AI: general-purpose LLM scratchpad --> | |
| <div id="askModal" class="modal" hidden> | |
| <div class="modal-backdrop"></div> | |
| <div class="modal-box modal-box-lg"> | |
| <div class="modal-head"> | |
| <span class="modal-title">Ask AI</span> | |
| <span class="spacer"></span> | |
| <button id="askCloseBtn" class="btn">Close</button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="ai-gen"> | |
| <label class="llm-field"> | |
| <span class="llm-label">Template <span class="muted">(optional prefill)</span></span> | |
| <select id="askTemplate" class="llm-input"></select> | |
| </label> | |
| <label class="llm-field"> | |
| <span class="llm-label">System prompt</span> | |
| <textarea id="askSystem" class="ai-gen-ta" rows="3" spellcheck="false"></textarea> | |
| </label> | |
| <label class="llm-field"> | |
| <span class="llm-label">User prompt</span> | |
| <textarea id="askUser" class="ai-gen-ta" rows="4" spellcheck="false"></textarea> | |
| </label> | |
| <div class="gen-subj-row"> | |
| <span class="llm-label">Image <span class="muted">(optional)</span></span> | |
| <input id="askImageInput" type="file" accept="image/*" class="gen-file" /> | |
| <button id="askClearImgBtn" class="btn" type="button" hidden>Clear</button> | |
| <span class="spacer"></span> | |
| <button id="askRunBtn" class="btn primary" type="button">Send</button> | |
| <button id="askRefineBtn" class="btn" type="button" hidden | |
| title="Iterate: sends your prompt as a follow-up, keeping the conversation">Refine</button> | |
| <button id="askStopBtn" class="btn" type="button" hidden>Stop</button> | |
| <span id="askStatus" class="llm-status"></span> | |
| </div> | |
| <label class="llm-field llm-field-grow"> | |
| <span class="llm-label">Response <span class="muted">(editable)</span></span> | |
| <textarea id="askResult" class="ai-gen-ta ai-gen-result" rows="10" | |
| spellcheck="false" placeholder="The response will appear here…"></textarea> | |
| </label> | |
| <div class="ai-gen-actions"> | |
| <span class="spacer"></span> | |
| <button id="askCopyBtn" class="btn" type="button" disabled>Copy</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Prompt library: review the WHOLE MiniMax prompt across all scenes --> | |
| <div id="reviewModal" class="modal" hidden> | |
| <div class="modal-backdrop"></div> | |
| <div class="modal-box modal-box-lg"> | |
| <div class="modal-head"> | |
| <span id="reviewTitle" class="modal-title">Review prompt with AI</span> | |
| <span class="spacer"></span> | |
| <button id="reviewCloseBtn" class="btn">Close</button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="ai-gen"> | |
| <label class="llm-field"> | |
| <span class="llm-label">Template</span> | |
| <select id="reviewTemplate" class="llm-input"></select> | |
| </label> | |
| <label class="llm-field"> | |
| <span class="llm-label">System prompt</span> | |
| <textarea id="reviewSystem" class="ai-gen-ta" rows="4" spellcheck="false"></textarea> | |
| </label> | |
| <label class="llm-field"> | |
| <span class="llm-label">User prompt</span> | |
| <textarea id="reviewUser" class="ai-gen-ta" rows="2" spellcheck="false"></textarea> | |
| </label> | |
| <label class="llm-field"> | |
| <span id="reviewContextLabel" class="llm-label">Prompt under review <span class="muted">(read-only)</span></span> | |
| <textarea id="reviewContext" class="ai-gen-ta" rows="6" readonly spellcheck="false" | |
| title="The context sent to the model. Edit it in the scene editor."></textarea> | |
| </label> | |
| <div class="ai-gen-actions"> | |
| <button id="reviewRunBtn" class="btn primary" type="button">Review</button> | |
| <button id="reviewRefineBtn" class="btn" type="button" hidden | |
| title="Iterate: sends your user prompt as a follow-up, keeping the conversation">Refine</button> | |
| <button id="reviewStopBtn" class="btn" type="button" hidden>Stop</button> | |
| <span id="reviewStatus" class="llm-status"></span> | |
| </div> | |
| <label class="llm-field llm-field-grow"> | |
| <span class="llm-label">Review <span class="muted">(editable)</span></span> | |
| <textarea id="reviewResult" class="ai-gen-ta ai-gen-result" rows="10" | |
| spellcheck="false" placeholder="The review will appear here…"></textarea> | |
| </label> | |
| <div class="ai-gen-actions"> | |
| <button id="reviewApplyBtn" class="btn" type="button" hidden | |
| title="Generate a corrected scene that implements the review's suggestions">Apply suggestions</button> | |
| <span class="spacer"></span> | |
| <button id="reviewCopyBtn" class="btn" type="button" disabled>Copy</button> | |
| </div> | |
| <label id="reviewRewriteWrap" class="llm-field llm-field-grow" hidden> | |
| <span class="llm-label">Corrected scene <span class="muted">(editable — Accept applies summary + detailed_description)</span></span> | |
| <textarea id="reviewRewrite" class="ai-gen-ta ai-gen-result" rows="8" | |
| spellcheck="false" placeholder="The corrected scene will appear here…"></textarea> | |
| </label> | |
| <div id="reviewAcceptRow" class="ai-gen-actions" hidden> | |
| <span class="spacer"></span> | |
| <button id="reviewAcceptBtn" class="btn primary" type="button" disabled>Accept into scene</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Global AI/LLM settings dialog (shared by every feature) --> | |
| <div id="llmModal" class="modal" hidden> | |
| <div class="modal-backdrop"></div> | |
| <div class="modal-box"> | |
| <div class="modal-head"> | |
| <span class="modal-title">AI settings</span> | |
| <span class="spacer"></span> | |
| <button id="llmCloseBtn" class="btn">Close</button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="llm-form"> | |
| <p class="scene-hint"> | |
| Connect a local OpenAI-compatible LLM (LM Studio, Ollama, …) to generate, | |
| complete, and improve prompts across the app. In LM Studio: start the local | |
| server and enable CORS. Stored locally as settings only — never your content. | |
| </p> | |
| <label class="llm-field"> | |
| <span class="llm-label">Base URL</span> | |
| <input id="llmBaseUrl" type="text" class="llm-input" | |
| placeholder="http://localhost:1234/v1" autocomplete="off" spellcheck="false" /> | |
| </label> | |
| <div class="llm-field"> | |
| <span class="llm-label">Model</span> | |
| <div class="llm-model-row"> | |
| <input id="llmModel" type="text" class="llm-input" list="llmModelList" | |
| placeholder="e.g. llama-3.1-8b-instruct" autocomplete="off" spellcheck="false" /> | |
| <button id="llmRefreshBtn" class="btn" type="button" | |
| title="Fetch available models from the server">Refresh</button> | |
| </div> | |
| <datalist id="llmModelList"></datalist> | |
| </div> | |
| <label class="llm-field"> | |
| <span class="llm-label">API key <span class="muted">(optional — not needed for local)</span></span> | |
| <input id="llmApiKey" type="password" class="llm-input" | |
| placeholder="leave empty for local servers" autocomplete="off" /> | |
| </label> | |
| <label class="llm-field"> | |
| <span class="llm-label">Temperature</span> | |
| <input id="llmTemperature" type="number" class="llm-input llm-input-narrow" | |
| min="0" max="2" step="0.1" /> | |
| </label> | |
| <div class="llm-field"> | |
| <label class="llm-checkline"> | |
| <input id="llmIndependent" type="checkbox" /> | |
| <span>Independent context each request</span> | |
| </label> | |
| <p class="scene-hint"> | |
| Sends <code>cache_prompt=false</code> so the server doesn't reuse the previous | |
| request's context — prevents a prior image "leaking" into later text | |
| generations (LM Studio). Turn off for slightly faster repeated prompts. | |
| </p> | |
| </div> | |
| <div class="llm-actions"> | |
| <button id="llmTestBtn" class="btn" type="button">Test connection</button> | |
| <span id="llmStatus" class="llm-status"></span> | |
| <span class="spacer"></span> | |
| <button id="llmTemplatesBtn" class="btn" type="button" | |
| title="Edit the AI prompt templates used across the app">Prompt templates…</button> | |
| <button id="llmSaveBtn" class="btn primary" type="button">Save</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Generic modal: full example prompt, per-section guidance --> | |
| <div id="modal" class="modal" hidden> | |
| <div class="modal-backdrop"></div> | |
| <div class="modal-box"> | |
| <div class="modal-head"> | |
| <span id="modalTitle" class="modal-title"></span> | |
| <span class="spacer"></span> | |
| <button id="modalCopyBtn" class="btn" hidden>Copy</button> | |
| <button id="modalCloseBtn" class="btn">Close</button> | |
| </div> | |
| <div id="modalBody" class="modal-body"></div> | |
| </div> | |
| </div> | |
| <div id="toasts" class="toasts"></div> | |
| <script> | |
| (function () { | |
| var __mods = {}, __cache = {}; | |
| function register(id, fn) { __mods[id] = fn; } | |
| function require(id) { | |
| if (__cache[id]) return __cache[id].exports; | |
| var module = { exports: {} }; | |
| __cache[id] = module; | |
| __mods[id](module, module.exports, require); | |
| return module.exports; | |
| } | |
| register("js/app.js", function (module, exports, require) { | |
| const { FileSystemStorageAdapter } = require("js/storage/fs-adapter.js"); | |
| const { idbGet, idbSet } = require("js/storage/idb.js"); | |
| const { createGrid } = require("js/grid.js"); | |
| const { initModal } = require("js/modal.js"); | |
| const { toast } = require("js/toast.js"); | |
| const { initLibrary, focusSearch: libraryFocusSearch, isDirty: libraryDirty, onShow: libraryOnShow } = require("js/prompt-library.js"); | |
| const { state, applyFilters, effectivePrompt, hasEffectivePrompt, isDirty, withCount } = require("js/store.js"); | |
| const { loadLlmConfig, onLlmStatusChange, isLlmOnline } = require("js/llm/llm-config.js"); | |
| const { initLlmSettings, openLlmSettings, refreshLlmStatus } = require("js/llm/llm-settings.js"); | |
| const { loadLlmTemplates } = require("js/llm/llm-prompts.js"); | |
| const { initPromptTemplates } = require("js/llm/llm-prompt-templates.js"); | |
| const { initImageAi, openImageAi } = require("js/image-ai.js"); | |
| const { initTextAi } = require("js/text-ai.js"); | |
| const { initGlobalsAi } = require("js/globals-ai.js"); | |
| const { initReviewAi } = require("js/review-ai.js"); | |
| const { initAskAi, openAskAi } = require("js/ask-ai.js"); | |
| const adapter = new FileSystemStorageAdapter(); | |
| // ---- DOM refs ---- | |
| const $ = (id) => document.getElementById(id); | |
| const el = { | |
| unsupported: $("unsupported"), | |
| openBtn: $("openBtn"), | |
| reopenBtn: $("reopenBtn"), | |
| folderName: $("folderName"), | |
| search: $("search"), | |
| filters: document.querySelector(".filters"), | |
| counts: $("counts"), | |
| gridView: $("gridView"), | |
| grid: $("grid"), | |
| gridInner: $("gridInner"), | |
| empty: $("empty"), | |
| detailView: $("detailView"), | |
| detailTitle: $("detailTitle"), | |
| dirtyFlag: $("dirtyFlag"), | |
| detailImg: $("detailImg"), | |
| promptEditor: $("promptEditor"), | |
| detailMeta: $("detailMeta"), | |
| backBtn: $("backBtn"), | |
| toggleImgBtn: $("toggleImgBtn"), | |
| renameBtn: $("renameBtn"), | |
| deleteBtn: $("deleteBtn"), | |
| prevBtn: $("prevBtn"), | |
| nextBtn: $("nextBtn"), | |
| aiGenBtn: $("aiGenBtn"), | |
| copyBtn: $("copyBtn"), | |
| saveBtn: $("saveBtn"), | |
| // section switching | |
| imagesSection: $("imagesSection"), | |
| librarySection: $("librarySection"), | |
| tabs: document.querySelector(".sections"), | |
| llmSettingsBtn: $("llmSettingsBtn"), | |
| askAiBtn: $("askAiBtn"), | |
| }; | |
| // ---- thumbnail URL cache (object URLs are reused across renders) ---- | |
| const urlCache = new Map(); // thumbKey -> objectURL | |
| const inflight = new Map(); // thumbKey -> Promise<objectURL> | |
| function loadThumb(entry, priority) { | |
| const k = entry.thumbKey; | |
| if (urlCache.has(k)) return Promise.resolve(urlCache.get(k)); | |
| if (inflight.has(k)) return inflight.get(k); | |
| const p = adapter | |
| .getThumbnail(entry, { priority }) | |
| .then((blob) => { | |
| const url = URL.createObjectURL(blob); | |
| urlCache.set(k, url); | |
| inflight.delete(k); | |
| return url; | |
| }) | |
| .catch((err) => { | |
| inflight.delete(k); | |
| throw err; | |
| }); | |
| inflight.set(k, p); | |
| return p; | |
| } | |
| // Warm the on-disk cache without holding an object URL (phase-3 backfill, §4). | |
| function warm(entry) { | |
| if (urlCache.has(entry.thumbKey) || inflight.has(entry.thumbKey)) return; | |
| adapter.getThumbnail(entry, { priority: false }).catch(() => {}); | |
| } | |
| const grid = createGrid(el.grid, el.gridInner, { loadThumb, onOpen: openDetail }); | |
| // ---- rendering helpers ---- | |
| function updateCounts() { | |
| if (!state.entries.length) { | |
| el.counts.textContent = ""; | |
| return; | |
| } | |
| el.counts.textContent = `${withCount()} / ${state.entries.length} with prompt`; | |
| } | |
| function renderGrid() { | |
| el.empty.hidden = state.entries.length > 0; | |
| updateCounts(); | |
| grid.refresh(); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Detail view (spec §5.3, §9.3, §9.4) | |
| // --------------------------------------------------------------------------- | |
| const originalUrls = new Map(); // name -> objectURL (bounded to prev/cur/next) | |
| async function getOriginalUrl(entry) { | |
| if (originalUrls.has(entry.name)) return originalUrls.get(entry.name); | |
| const file = await adapter.getFile(entry); | |
| const url = URL.createObjectURL(file); | |
| originalUrls.set(entry.name, url); | |
| return url; | |
| } | |
| function pruneOriginals(keepNames) { | |
| for (const [name, url] of originalUrls) { | |
| if (!keepNames.has(name)) { | |
| URL.revokeObjectURL(url); | |
| originalUrls.delete(name); | |
| } | |
| } | |
| } | |
| // The image currently open in the detail view. Tracked as an object (not just | |
| // an index) so that saving keeps us on the SAME image even when it leaves the | |
| // active filter (e.g. gaining a prompt under the "No prompt" filter). | |
| let currentImage = null; | |
| function currentEntry() { | |
| return currentImage; | |
| } | |
| // Position of the open image within the filtered list for prev/next; falls back | |
| // to the last known index if the image has dropped out of the current filter. | |
| function currentIndex() { | |
| if (!currentImage) return -1; | |
| const i = state.filtered.indexOf(currentImage); | |
| return i >= 0 ? i : state.selectedIndex; | |
| } | |
| function updateDirtyUI() { | |
| const e = currentEntry(); | |
| const dirty = e ? isDirty(e) : false; | |
| el.dirtyFlag.hidden = !dirty; | |
| el.saveBtn.disabled = !dirty; | |
| } | |
| /** Enable "Generate with AI" only while online and viewing an image. */ | |
| function updateAiBtn() { | |
| const online = isLlmOnline(); | |
| el.aiGenBtn.disabled = !(online && currentImage && state.view === "detail"); | |
| el.aiGenBtn.title = online | |
| ? "Generate the prompt from the image with AI" | |
| : "AI not connected — configure it in AI settings"; | |
| } | |
| function updateMeta(entry) { | |
| const kb = (entry.size / 1024).toFixed(0); | |
| const when = new Date(entry.mtime).toLocaleString(); | |
| const status = hasEffectivePrompt(entry) ? "has prompt" : "no prompt"; | |
| el.detailMeta.textContent = `${entry.name} · ${entry.ext.toUpperCase()} · ${kb} KB · ${when} · ${status}`; | |
| } | |
| // Privacy toggle (spec §5.3): hide/show the original in the detail view. The | |
| // state persists as you navigate so nothing is revealed by accident. Hiding | |
| // also drops the <img> src so the pixels leave the DOM entirely. | |
| function syncImageVisibility() { | |
| el.detailView.classList.toggle("img-hidden", state.imageHidden); | |
| el.toggleImgBtn.textContent = state.imageHidden ? "⟨ Show image" : "Hide image ⟩"; | |
| el.toggleImgBtn.title = state.imageHidden | |
| ? "Show the image again" | |
| : "Hide the image for privacy"; | |
| } | |
| function showOriginal(entry) { | |
| getOriginalUrl(entry).then((url) => { | |
| if (currentEntry() === entry && !state.imageHidden) el.detailImg.src = url; | |
| }); | |
| } | |
| function toggleImageVisibility() { | |
| state.imageHidden = !state.imageHidden; | |
| syncImageVisibility(); | |
| const e = currentEntry(); | |
| if (!e) return; | |
| if (state.imageHidden) el.detailImg.removeAttribute("src"); | |
| else showOriginal(e); | |
| } | |
| async function openDetail(index) { | |
| if (index < 0 || index >= state.filtered.length) return; | |
| state.selectedIndex = index; | |
| state.view = "detail"; | |
| const e = state.filtered[index]; | |
| currentImage = e; | |
| el.detailView.hidden = false; | |
| el.detailTitle.textContent = e.name; | |
| el.promptEditor.value = effectivePrompt(e) ?? ""; | |
| updateDirtyUI(); | |
| updateAiBtn(); | |
| updateMeta(e); | |
| syncImageVisibility(); | |
| el.prevBtn.disabled = index === 0; | |
| el.nextBtn.disabled = index === state.filtered.length - 1; | |
| el.detailImg.removeAttribute("src"); | |
| if (!state.imageHidden) showOriginal(e); | |
| // Preload neighbors — the dominant flow when captioning sequentially (§5.3). | |
| const keep = new Set([e.name]); | |
| for (const j of [index - 1, index + 1]) { | |
| const n = state.filtered[j]; | |
| if (n) { | |
| keep.add(n.name); | |
| getOriginalUrl(n).catch(() => {}); | |
| } | |
| } | |
| pruneOriginals(keep); | |
| el.promptEditor.focus(); | |
| } | |
| function backToGrid() { | |
| state.view = "grid"; | |
| el.detailView.hidden = true; | |
| updateAiBtn(); | |
| grid.layout(); | |
| grid.scrollToIndex(state.selectedIndex); | |
| } | |
| function nav(delta) { | |
| const next = currentIndex() + delta; | |
| if (next >= 0 && next < state.filtered.length) openDetail(next); | |
| } | |
| async function saveCurrent() { | |
| const e = currentEntry(); | |
| if (!e || !isDirty(e)) return true; | |
| try { | |
| await adapter.writePrompt(e, el.promptEditor.value); | |
| state.dirty.delete(e.baseName); | |
| applyFilters(); | |
| updateCounts(); | |
| updateDirtyUI(); | |
| return true; | |
| } catch (err) { | |
| toast("Could not save: " + err.message, "error"); | |
| return false; | |
| } | |
| } | |
| // Save and STAY on the current image (no auto-advance). Navigation is manual. | |
| async function saveStay() { | |
| const e = currentEntry(); | |
| if (!e) return; | |
| const oldIdx = state.selectedIndex; | |
| if (!(await saveCurrent())) return; | |
| // Re-sync navigation position. If the image is still in the filtered view, | |
| // point at it. If saving dropped it out (e.g. under the "No prompt" filter), | |
| // keep it displayed and place the cursor so → advances to the image that | |
| // took its place. | |
| const idx = state.filtered.indexOf(currentImage); | |
| state.selectedIndex = idx >= 0 ? idx : Math.max(0, oldIdx) - 1; | |
| const navIdx = currentIndex(); | |
| el.prevBtn.disabled = navIdx <= 0; | |
| el.nextBtn.disabled = navIdx >= state.filtered.length - 1; | |
| updateMeta(e); | |
| updateDirtyUI(); | |
| } | |
| async function copyPrompt() { | |
| const e = currentEntry(); | |
| if (!e) return; | |
| try { | |
| await navigator.clipboard.writeText(effectivePrompt(e) ?? ""); | |
| toast("Prompt copied."); | |
| } catch { | |
| toast("Clipboard blocked by the browser.", "warn"); | |
| } | |
| } | |
| el.promptEditor.addEventListener("input", () => { | |
| const e = currentEntry(); | |
| if (!e) return; | |
| const cur = el.promptEditor.value; | |
| const disk = e.prompt ?? ""; | |
| if (cur === disk) state.dirty.delete(e.baseName); | |
| else state.dirty.set(e.baseName, { text: cur, base: disk }); | |
| updateDirtyUI(); | |
| }); | |
| async function renameCurrentImage() { | |
| const e = currentEntry(); | |
| if (!e) return; | |
| const input = window.prompt("New name (without extension):", e.baseName); | |
| if (input == null) return; // cancelled | |
| const newBase = input.trim(); | |
| if (!newBase || newBase === e.baseName) return; | |
| const oldBase = e.baseName; | |
| try { | |
| await adapter.renameImage(e, newBase); // renames image + its .txt together | |
| } catch (err) { | |
| toast("Could not rename: " + err.message, "error"); | |
| return; | |
| } | |
| // Carry any unsaved prompt edit over to the new base-name key. | |
| if (state.dirty.has(oldBase)) { | |
| state.dirty.set(e.baseName, state.dirty.get(oldBase)); | |
| state.dirty.delete(oldBase); | |
| } | |
| el.detailTitle.textContent = e.name; | |
| updateMeta(e); | |
| applyFilters(); | |
| grid.refresh(); | |
| toast(`Renamed to "${e.name}".`); | |
| } | |
| // Delete the open image and its .txt sidecar, then land on the image that took | |
| // its place (same slot in the filtered list), or fall back to the grid when the | |
| // folder — or the current filter — is left empty. | |
| async function deleteCurrentImage() { | |
| const e = currentEntry(); | |
| if (!e) return; | |
| if ( | |
| !window.confirm( | |
| `Delete "${e.name}"${e.hasPrompt ? " and its .txt prompt" : ""}? This removes it from disk.` | |
| ) | |
| ) | |
| return; | |
| try { | |
| await adapter.deleteImage(e); | |
| } catch (err) { | |
| toast("Could not delete: " + err.message, "error"); | |
| return; | |
| } | |
| // Drop any in-memory references to the removed image. | |
| state.dirty.delete(e.baseName); | |
| const orig = originalUrls.get(e.name); | |
| if (orig) { | |
| URL.revokeObjectURL(orig); | |
| originalUrls.delete(e.name); | |
| } | |
| const thumb = urlCache.get(e.thumbKey); | |
| if (thumb) { | |
| URL.revokeObjectURL(thumb); | |
| urlCache.delete(e.thumbKey); | |
| } | |
| const slot = currentIndex(); | |
| const gi = state.entries.indexOf(e); | |
| if (gi >= 0) state.entries.splice(gi, 1); | |
| applyFilters(); | |
| updateCounts(); | |
| toast(`Deleted "${e.name}".`); | |
| if (state.filtered.length === 0) { | |
| currentImage = null; | |
| state.selectedIndex = -1; | |
| backToGrid(); | |
| renderGrid(); | |
| return; | |
| } | |
| openDetail(Math.min(Math.max(slot, 0), state.filtered.length - 1)); | |
| } | |
| el.backBtn.addEventListener("click", backToGrid); | |
| el.toggleImgBtn.addEventListener("click", toggleImageVisibility); | |
| el.renameBtn.addEventListener("click", renameCurrentImage); | |
| el.deleteBtn.addEventListener("click", deleteCurrentImage); | |
| el.prevBtn.addEventListener("click", () => nav(-1)); | |
| el.nextBtn.addEventListener("click", () => nav(1)); | |
| el.aiGenBtn.addEventListener("click", () => openImageAi(currentEntry())); | |
| el.copyBtn.addEventListener("click", copyPrompt); | |
| el.saveBtn.addEventListener("click", saveStay); | |
| // --------------------------------------------------------------------------- | |
| // Filters & search (spec §9.2) | |
| // --------------------------------------------------------------------------- | |
| el.filters.addEventListener("click", (ev) => { | |
| const btn = ev.target.closest(".chip"); | |
| if (!btn) return; | |
| state.filterMode = btn.dataset.filter; | |
| for (const c of el.filters.querySelectorAll(".chip")) | |
| c.classList.toggle("active", c === btn); | |
| applyFilters(); | |
| renderGrid(); | |
| }); | |
| el.search.addEventListener("input", () => { | |
| state.search = el.search.value; | |
| applyFilters(); | |
| renderGrid(); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Section navigation (Image prompts / Prompt library) | |
| // --------------------------------------------------------------------------- | |
| function switchSection(name) { | |
| if (name === state.section) return; | |
| state.section = name; | |
| el.imagesSection.hidden = name !== "images"; | |
| el.librarySection.hidden = name !== "library"; | |
| for (const t of el.tabs.querySelectorAll(".tab")) | |
| t.classList.toggle("active", t.dataset.section === name); | |
| // Sections were hidden (0-size) while away — re-measure on return. | |
| if (name === "images") grid.layout(); | |
| else if (name === "library") libraryOnShow(); | |
| } | |
| el.tabs.addEventListener("click", (ev) => { | |
| const btn = ev.target.closest(".tab"); | |
| if (btn) switchSection(btn.dataset.section); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Keyboard shortcuts (spec §9.4) | |
| // --------------------------------------------------------------------------- | |
| document.addEventListener("keydown", (ev) => { | |
| const mod = ev.ctrlKey || ev.metaKey; | |
| // Ctrl+F focuses the active section's search. | |
| if (mod && (ev.key === "f" || ev.key === "F")) { | |
| ev.preventDefault(); | |
| if (state.section === "library") { | |
| libraryFocusSearch(); | |
| } else { | |
| el.search.focus(); | |
| el.search.select(); | |
| } | |
| return; | |
| } | |
| // The remaining shortcuts belong to the image detail view only. | |
| if (state.section !== "images") return; | |
| if (state.view !== "detail") return; | |
| if (mod && ev.key === "Enter") { | |
| ev.preventDefault(); | |
| saveStay(); | |
| return; | |
| } | |
| if (ev.key === "Escape") { | |
| ev.preventDefault(); | |
| backToGrid(); | |
| return; | |
| } | |
| const editing = document.activeElement === el.promptEditor; | |
| if (!editing) { | |
| if (ev.key === "ArrowLeft") { | |
| ev.preventDefault(); | |
| nav(-1); | |
| } else if (ev.key === "ArrowRight") { | |
| ev.preventDefault(); | |
| nav(1); | |
| } else if (mod && (ev.key === "c" || ev.key === "C")) { | |
| ev.preventDefault(); | |
| copyPrompt(); | |
| } | |
| } | |
| }); | |
| // Warn before leaving the page with unsaved edits (spec §7.1). | |
| window.addEventListener("beforeunload", (ev) => { | |
| if (state.dirty.size || libraryDirty()) { | |
| ev.preventDefault(); // modern Chromium shows the leave-site prompt | |
| } | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Opening folders | |
| // --------------------------------------------------------------------------- | |
| async function openFolder(handle) { | |
| const entries = await adapter.listFolder(handle); | |
| state.entries = entries; | |
| state.filtered = []; | |
| state.folderName = handle.name; | |
| state.writable = adapter.writable; | |
| state.dirty.clear(); | |
| state.view = "grid"; | |
| el.detailView.hidden = true; | |
| el.folderName.textContent = handle.name; | |
| applyFilters(); | |
| renderGrid(); | |
| await idbSet("lastFolder", handle).catch(() => {}); | |
| if (!adapter.writable) { | |
| toast( | |
| "Folder is read-only — thumbnails won't be cached to disk and prompts can't be saved.", | |
| "warn" | |
| ); | |
| } else { | |
| startBackfill(); | |
| } | |
| } | |
| el.openBtn.addEventListener("click", async () => { | |
| try { | |
| const handle = await window.showDirectoryPicker({ mode: "readwrite" }); | |
| el.reopenBtn.hidden = true; | |
| await openFolder(handle); | |
| } catch (err) { | |
| if (err && err.name !== "AbortError") | |
| toast("Could not open folder: " + err.message, "error"); | |
| } | |
| }); | |
| // Phase-3 backfill: generate the thumbnails we haven't shown yet, at idle. | |
| function startBackfill() { | |
| const list = state.entries.slice(); | |
| let i = 0; | |
| const step = (deadline) => { | |
| while ( | |
| i < list.length && | |
| (!deadline || deadline.timeRemaining() > 3) | |
| ) { | |
| warm(list[i++]); | |
| } | |
| if (i < list.length) requestIdleCallback(step); | |
| }; | |
| if ("requestIdleCallback" in window) requestIdleCallback(step); | |
| else setTimeout(() => step(null), 500); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // External-change detection: re-scan on focus (spec §7.2) | |
| // --------------------------------------------------------------------------- | |
| let scanning = false; | |
| async function rescan() { | |
| if (!adapter.dirHandle || scanning) return; | |
| scanning = true; | |
| try { | |
| const openEntry = state.view === "detail" ? currentEntry() : null; | |
| const openName = openEntry ? openEntry.name : null; | |
| const fresh = await adapter.listFolder(adapter.dirHandle); | |
| for (const e of fresh) { | |
| const d = state.dirty.get(e.baseName); | |
| if (!d) continue; | |
| const disk = e.prompt ?? ""; | |
| if (disk !== (d.base ?? "")) { | |
| // Disk changed underneath an unsaved edit -> conflict (§7.2). | |
| if (e.name === openName) showConflict(e, d, disk); | |
| else | |
| toast( | |
| `"${e.name}" changed on disk — kept your unsaved edit.`, | |
| "warn" | |
| ); | |
| } | |
| } | |
| state.entries = fresh; | |
| applyFilters(); | |
| if (openName) { | |
| const idx = state.filtered.findIndex((x) => x.name === openName); | |
| if (idx >= 0) { | |
| state.selectedIndex = idx; | |
| const e = state.filtered[idx]; | |
| currentImage = e; // re-list produced fresh objects; drop the stale ref | |
| if (!isDirty(e)) el.promptEditor.value = e.prompt ?? ""; | |
| updateMeta(e); | |
| updateDirtyUI(); | |
| } | |
| } | |
| updateCounts(); | |
| if (state.view === "grid") grid.refresh(); | |
| } catch { | |
| /* transient errors on focus are non-fatal */ | |
| } finally { | |
| scanning = false; | |
| } | |
| } | |
| function showConflict(entry, dirtyRec, diskPrompt) { | |
| toast( | |
| `"${entry.name}" was changed on disk while you have unsaved edits.`, | |
| "warn", | |
| [ | |
| { | |
| label: "Keep mine", | |
| onClick: () => { | |
| // Rebase so we don't warn again for the same disk state. | |
| dirtyRec.base = diskPrompt; | |
| state.dirty.set(entry.baseName, dirtyRec); | |
| }, | |
| }, | |
| { | |
| label: "Load disk", | |
| onClick: () => { | |
| state.dirty.delete(entry.baseName); | |
| if (currentEntry() && currentEntry().name === entry.name) { | |
| el.promptEditor.value = diskPrompt; | |
| } | |
| entry.prompt = diskPrompt; | |
| applyFilters(); | |
| updateDirtyUI(); | |
| updateCounts(); | |
| }, | |
| }, | |
| ] | |
| ); | |
| } | |
| window.addEventListener("focus", rescan); | |
| // --------------------------------------------------------------------------- | |
| // Startup | |
| // --------------------------------------------------------------------------- | |
| async function init() { | |
| if (!("showDirectoryPicker" in window)) { | |
| el.unsupported.hidden = false; | |
| el.openBtn.disabled = true; | |
| return; | |
| } | |
| initModal(); | |
| initLibrary(); | |
| // Global AI/LLM connection: load saved settings and wire the settings dialog. | |
| // initPromptTemplates() first so its keydown handler is registered after the | |
| // settings one — the settings dialog then defers Escape while it is open. | |
| initLlmSettings(); | |
| initPromptTemplates(); | |
| el.llmSettingsBtn.addEventListener("click", openLlmSettings); | |
| // Image "Generate with AI": applies the accepted prompt through the normal | |
| // editor flow (marks dirty, user reviews, then saves). | |
| initImageAi({ | |
| getFile: (entry) => adapter.getFile(entry), | |
| applyPrompt: (text) => { | |
| el.promptEditor.value = text; | |
| el.promptEditor.dispatchEvent(new Event("input", { bubbles: true })); | |
| el.promptEditor.focus(); | |
| }, | |
| }); | |
| // Text generator used by the Prompt Library Generate features (scene, default). | |
| initTextAi(); | |
| initGlobalsAi(); | |
| // Whole-prompt reviewer for the Prompt Library (MiniMax); advisory report only. | |
| initReviewAi(); | |
| // General-purpose LLM scratchpad, opened from the nav. | |
| initAskAi(); | |
| el.askAiBtn.addEventListener("click", openAskAi); | |
| // Keep the "Generate" button in sync with connectivity as probes complete. | |
| onLlmStatusChange(updateAiBtn); | |
| await Promise.all([loadLlmConfig(), loadLlmTemplates()]); | |
| refreshLlmStatus(); // fire-and-forget: set the nav indicator without blocking startup | |
| const handle = await idbGet("lastFolder").catch(() => null); | |
| if (handle) { | |
| el.reopenBtn.hidden = false; | |
| el.reopenBtn.textContent = `Reopen "${handle.name}"`; | |
| el.reopenBtn.addEventListener("click", async () => { | |
| let perm = await handle.queryPermission({ mode: "readwrite" }); | |
| if (perm !== "granted") | |
| perm = await handle.requestPermission({ mode: "readwrite" }); | |
| if (perm === "granted") { | |
| el.reopenBtn.hidden = true; | |
| await openFolder(handle); | |
| } else { | |
| toast("Permission denied.", "error"); | |
| } | |
| }); | |
| } | |
| } | |
| init(); | |
| }); | |
| register("js/storage/fs-adapter.js", function (module, exports, require) { | |
| const { hashKey } = require("js/hash.js"); | |
| const { createThumbnailWorker } = require("js/workers/worker-factory.js"); | |
| /** | |
| * The ONLY module that talks to the File System Access API (spec §6). | |
| * No UI component may call the file API directly; everything goes through here. | |
| * Swapping this out for a Tauri implementation is the entire migration path. | |
| * | |
| * @typedef {Object} ImageEntry | |
| * @property {string} name "img_001.png" | |
| * @property {string} baseName "img_001" | |
| * @property {string} ext "png" | |
| * @property {number} mtime image file lastModified (drives thumbKey) | |
| * @property {number} size image file size in bytes | |
| * @property {number} promptMtime prompt .txt lastModified, or 0 | |
| * @property {boolean} hasPrompt | |
| * @property {string|null} prompt prompt text loaded during scan, or null | |
| * @property {string} thumbKey hash(name + mtime) | |
| * @property {FileSystemFileHandle} _imageHandle (adapter-internal) | |
| * @property {FileSystemFileHandle|null} _promptHandle (adapter-internal) | |
| */ | |
| const THUMB_DIR = ".thumbnails"; | |
| const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "webp"]); | |
| function extOf(name) { | |
| const dot = name.lastIndexOf("."); | |
| return dot < 0 ? "" : name.slice(dot + 1).toLowerCase(); | |
| } | |
| /** Make a filesystem-safe base name (no separators, no leading dots). */ | |
| function sanitizeBaseName(name) { | |
| return name | |
| .replace(/[\/\\:*?"<>|\x00-\x1F]/g, "") | |
| .replace(/^\.+/, "") | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| } | |
| /** Fixed-size pool of thumbnail workers with a small priority queue. */ | |
| class WorkerPool { | |
| constructor(createWorker, size) { | |
| this.idle = []; | |
| this.queue = []; | |
| this.pending = new Map(); | |
| this.seq = 0; | |
| for (let i = 0; i < size; i++) { | |
| const w = createWorker(); | |
| w.onmessage = (e) => this.#onDone(w, e.data); | |
| this.idle.push(w); | |
| } | |
| } | |
| /** @param {Blob} blob @param {boolean} priority */ | |
| resize(blob, priority = false) { | |
| return new Promise((resolve, reject) => { | |
| const id = ++this.seq; | |
| this.pending.set(id, { resolve, reject }); | |
| const job = { id, blob }; | |
| if (priority) this.queue.unshift(job); | |
| else this.queue.push(job); | |
| this.#dispatch(); | |
| }); | |
| } | |
| #dispatch() { | |
| while (this.idle.length && this.queue.length) { | |
| const w = this.idle.pop(); | |
| const job = this.queue.shift(); | |
| w.postMessage({ id: job.id, blob: job.blob }); | |
| } | |
| } | |
| #onDone(w, data) { | |
| const p = this.pending.get(data.id); | |
| this.pending.delete(data.id); | |
| this.idle.push(w); | |
| if (p) { | |
| if (data.error) p.reject(new Error(data.error)); | |
| else p.resolve(data.blob); | |
| } | |
| this.#dispatch(); | |
| } | |
| } | |
| class FileSystemStorageAdapter { | |
| constructor() { | |
| /** @type {FileSystemDirectoryHandle|null} */ | |
| this.dirHandle = null; | |
| /** @type {FileSystemDirectoryHandle|null} */ | |
| this.thumbDirHandle = null; | |
| /** True when we can create/write files in the folder. */ | |
| this.writable = true; | |
| this.pool = new WorkerPool(createThumbnailWorker, 4); | |
| } | |
| /** | |
| * Phase-1 scan (spec §4.1). Non-recursive; skips service directories. | |
| * Reads each prompt sidecar so the app is immediately usable. | |
| * @param {FileSystemDirectoryHandle} handle | |
| * @returns {Promise<ImageEntry[]>} | |
| */ | |
| async listFolder(handle) { | |
| this.dirHandle = handle; | |
| await this.#ensureThumbDir(); | |
| /** @type {Map<string, FileSystemFileHandle>} */ | |
| const files = new Map(); | |
| for await (const [name, h] of handle.entries()) { | |
| // Skip everything that is not a file. This excludes .thumbnails/ and any | |
| // other subdirectory — we never walk recursively (spec §8). | |
| if (h.kind !== "file") continue; | |
| if (name === THUMB_DIR) continue; | |
| files.set(name, h); | |
| } | |
| const entries = []; | |
| for (const [name, h] of files) { | |
| const ext = extOf(name); | |
| if (!IMAGE_EXTS.has(ext)) continue; | |
| const base = name.slice(0, name.length - ext.length - 1); | |
| const file = await h.getFile(); | |
| const promptHandle = files.get(base + ".txt") || null; | |
| let prompt = null; | |
| let promptMtime = 0; | |
| if (promptHandle) { | |
| const pf = await promptHandle.getFile(); | |
| prompt = await pf.text(); | |
| promptMtime = pf.lastModified; | |
| } | |
| entries.push({ | |
| name, | |
| baseName: base, | |
| ext, | |
| mtime: file.lastModified, | |
| size: file.size, | |
| promptMtime, | |
| hasPrompt: promptHandle != null, | |
| prompt, | |
| thumbKey: hashKey(name + ":" + file.lastModified), | |
| _imageHandle: h, | |
| _promptHandle: promptHandle, | |
| }); | |
| } | |
| entries.sort((a, b) => | |
| a.name.localeCompare(b.name, undefined, { numeric: true }) | |
| ); | |
| return entries; | |
| } | |
| /** | |
| * Re-read a prompt from disk (used for external-change detection, §7.2). | |
| * @param {ImageEntry} entry | |
| * @returns {Promise<string|null>} | |
| */ | |
| async readPrompt(entry) { | |
| if (!entry._promptHandle) return null; | |
| const f = await entry._promptHandle.getFile(); | |
| return f.text(); | |
| } | |
| /** | |
| * Persist a prompt, replacing the whole file. Atomic via createWritable | |
| * (spec §7.1): the API writes a temp .crswap and swaps on close(). On error | |
| * we abort() so the original is never touched. | |
| * @param {ImageEntry} entry | |
| * @param {string} text | |
| */ | |
| async writePrompt(entry, text) { | |
| if (!entry._promptHandle) { | |
| entry._promptHandle = await this.dirHandle.getFileHandle( | |
| entry.baseName + ".txt", | |
| { create: true } | |
| ); | |
| } | |
| const writable = await entry._promptHandle.createWritable(); | |
| try { | |
| await writable.write(text); | |
| await writable.close(); // atomic commit | |
| } catch (err) { | |
| await writable.abort(); // discard without touching the original | |
| throw err; | |
| } | |
| const pf = await entry._promptHandle.getFile(); | |
| entry.prompt = text; | |
| entry.hasPrompt = true; | |
| entry.promptMtime = pf.lastModified; | |
| } | |
| /** | |
| * Return a thumbnail Blob. Serves from the on-disk .webp cache when present, | |
| * otherwise generates in a worker and (if writable) caches to disk. | |
| * @param {ImageEntry} entry | |
| * @param {{priority?: boolean}} [opts] | |
| * @returns {Promise<Blob>} | |
| */ | |
| async getThumbnail(entry, { priority = false } = {}) { | |
| const cacheName = entry.thumbKey + ".webp"; | |
| if (this.thumbDirHandle) { | |
| try { | |
| const h = await this.thumbDirHandle.getFileHandle(cacheName); | |
| const f = await h.getFile(); | |
| if (f.size > 0) return f; | |
| } catch { | |
| /* not cached yet — fall through to generation */ | |
| } | |
| } | |
| const original = await entry._imageHandle.getFile(); | |
| const blob = await this.pool.resize(original, priority); | |
| if (this.thumbDirHandle && this.writable) { | |
| try { | |
| const h = await this.thumbDirHandle.getFileHandle(cacheName, { | |
| create: true, | |
| }); | |
| const w = await h.createWritable(); | |
| await w.write(blob); | |
| await w.close(); | |
| } catch { | |
| /* caching is best-effort; ignore write failures */ | |
| } | |
| } | |
| return blob; | |
| } | |
| /** | |
| * The full-resolution original, for the detail view (spec §5.3). | |
| * @param {ImageEntry} entry | |
| * @returns {Promise<File>} | |
| */ | |
| async getFile(entry) { | |
| return entry._imageHandle.getFile(); | |
| } | |
| /** | |
| * Rename an image and its prompt sidecar together. Both `<base>.<ext>` and | |
| * `<base>.txt` (if present) are renamed to the new base name. Uses the native | |
| * `FileSystemFileHandle.move()` when available, else copy-then-delete. | |
| * @param {ImageEntry} entry mutated in place on success | |
| * @param {string} newBaseName | |
| */ | |
| async renameImage(entry, newBaseName) { | |
| const clean = sanitizeBaseName(newBaseName); | |
| if (!clean) throw new Error("Name cannot be empty."); | |
| if (clean === entry.baseName) return entry; | |
| const newImageName = clean + "." + entry.ext; | |
| const newPromptName = clean + ".txt"; | |
| // Fail before touching anything if either target is taken. | |
| await this.#assertFree(newImageName); | |
| if (entry._promptHandle) await this.#assertFree(newPromptName); | |
| entry._imageHandle = await this.#moveFile( | |
| entry._imageHandle, | |
| entry.name, | |
| newImageName | |
| ); | |
| if (entry._promptHandle) { | |
| entry._promptHandle = await this.#moveFile( | |
| entry._promptHandle, | |
| entry.baseName + ".txt", | |
| newPromptName | |
| ); | |
| } | |
| const f = await entry._imageHandle.getFile(); | |
| entry.name = newImageName; | |
| entry.baseName = clean; | |
| entry.mtime = f.lastModified; | |
| entry.size = f.size; | |
| entry.thumbKey = hashKey(newImageName + ":" + f.lastModified); | |
| if (entry._promptHandle) { | |
| entry.promptMtime = (await entry._promptHandle.getFile()).lastModified; | |
| } | |
| return entry; | |
| } | |
| /** | |
| * Delete an image and its prompt sidecar together. Removes `<base>.<ext>` and | |
| * `<base>.txt` (if present) from disk, plus the disposable thumbnail cache | |
| * entry (best-effort — the cache is never counted and safe to leave behind). | |
| * The image file is removed last so a failure never leaves an orphaned prompt. | |
| * @param {ImageEntry} entry | |
| */ | |
| async deleteImage(entry) { | |
| if (entry._promptHandle) { | |
| await this.dirHandle.removeEntry(entry.baseName + ".txt"); | |
| entry._promptHandle = null; | |
| } | |
| await this.dirHandle.removeEntry(entry.name); | |
| entry._imageHandle = null; | |
| if (this.thumbDirHandle) { | |
| try { | |
| await this.thumbDirHandle.removeEntry(entry.thumbKey + ".webp"); | |
| } catch { | |
| /* thumbnail may not have been generated — nothing to clean up */ | |
| } | |
| } | |
| } | |
| async #assertFree(name) { | |
| let exists = false; | |
| try { | |
| await this.dirHandle.getFileHandle(name); | |
| exists = true; | |
| } catch (e) { | |
| if (e && e.name !== "NotFoundError") throw e; | |
| } | |
| if (exists) throw new Error(`"${name}" already exists in this folder.`); | |
| } | |
| async #moveFile(handle, oldName, newName) { | |
| if (typeof handle.move === "function") { | |
| await handle.move(newName); // handle now references newName | |
| return handle; | |
| } | |
| // Fallback: copy contents to a new file, then remove the old one. | |
| const file = await handle.getFile(); | |
| const nh = await this.dirHandle.getFileHandle(newName, { create: true }); | |
| const w = await nh.createWritable(); | |
| try { | |
| await w.write(file); | |
| await w.close(); | |
| } catch (e) { | |
| await w.abort(); | |
| throw e; | |
| } | |
| await this.dirHandle.removeEntry(oldName); | |
| return nh; | |
| } | |
| /** | |
| * Ensure .thumbnails/ exists with its self-excluding .gitignore (spec §8). | |
| * Failure here means the folder is read-only -> degraded mode (spec §7.4). | |
| */ | |
| async #ensureThumbDir() { | |
| try { | |
| this.thumbDirHandle = await this.dirHandle.getDirectoryHandle(THUMB_DIR, { | |
| create: true, | |
| }); | |
| try { | |
| await this.thumbDirHandle.getFileHandle(".gitignore"); | |
| } catch { | |
| const gi = await this.thumbDirHandle.getFileHandle(".gitignore", { | |
| create: true, | |
| }); | |
| const w = await gi.createWritable(); | |
| await w.write("*"); | |
| await w.close(); | |
| } | |
| this.writable = true; | |
| } catch { | |
| this.thumbDirHandle = null; | |
| this.writable = false; | |
| } | |
| } | |
| } | |
| exports.FileSystemStorageAdapter = FileSystemStorageAdapter; | |
| }); | |
| register("js/hash.js", function (module, exports, require) { | |
| /** | |
| * Cheap, synchronous, non-cryptographic string hash for thumbnail cache keys. | |
| * Two independent rolling hashes combined -> 16 hex chars. Collisions are | |
| * irrelevant here because the key already encodes name + mtime; a collision | |
| * would at worst reuse a stale thumbnail for an unrelated file with the exact | |
| * same (name, mtime), which cannot happen within one folder. | |
| * | |
| * @param {string} str | |
| * @returns {string} 16-char lowercase hex | |
| */ | |
| function hashKey(str) { | |
| let h1 = 0x811c9dc5; | |
| let h2 = 0x1000193; | |
| for (let i = 0; i < str.length; i++) { | |
| const c = str.charCodeAt(i); | |
| h1 = Math.imul(h1 ^ c, 0x01000193); | |
| h2 = Math.imul(h2 + c, 0x01000193) ^ (h2 >>> 7); | |
| } | |
| const hex = (n) => (n >>> 0).toString(16).padStart(8, "0"); | |
| return hex(h1) + hex(h2); | |
| } | |
| exports.hashKey = hashKey; | |
| }); | |
| register("js/workers/worker-factory.js", function (module, exports, require) { | |
| function createThumbnailWorker() { | |
| var src = "/**\n * Thumbnail worker (spec §5.2).\n *\n * Receives an original image Blob, produces a 256px-wide WebP thumbnail.\n * Runs entirely off the main thread and never touches the DOM.\n */\n\nself.onmessage = async (e) => {\n const { id, blob } = e.data;\n try {\n // resizeWidth scales width to 256 with proportional height. High quality\n // downscale happens inside the decoder, so we never hold the full-size\n // bitmap in JS memory.\n const bitmap = await createImageBitmap(blob, {\n resizeWidth: 256,\n resizeQuality: \"high\",\n });\n\n const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);\n const ctx = canvas.getContext(\"2d\");\n ctx.drawImage(bitmap, 0, 0);\n bitmap.close();\n\n const out = await canvas.convertToBlob({ type: \"image/webp\", quality: 0.8 });\n self.postMessage({ id, blob: out });\n } catch (err) {\n self.postMessage({ id, error: String((err && err.message) || err) });\n }\n};\n"; | |
| var url = URL.createObjectURL(new Blob([src], { type: "text/javascript" })); | |
| return new Worker(url); | |
| } | |
| exports.createThumbnailWorker = createThumbnailWorker; | |
| }); | |
| register("js/storage/idb.js", function (module, exports, require) { | |
| /** | |
| * Tiny IndexedDB key/value store. | |
| * | |
| * Per the spec (§7.3) IndexedDB is used ONLY for the directory-handle so the | |
| * folder can be re-authorized with a single click on the next visit. It is | |
| * NEVER used for prompts or image content — disk is the single source of truth. | |
| */ | |
| const DB_NAME = "image-prompt-manager"; | |
| const STORE = "kv"; | |
| const VERSION = 1; | |
| function openDB() { | |
| return new Promise((resolve, reject) => { | |
| const req = indexedDB.open(DB_NAME, VERSION); | |
| req.onupgradeneeded = () => { | |
| req.result.createObjectStore(STORE); | |
| }; | |
| req.onsuccess = () => resolve(req.result); | |
| req.onerror = () => reject(req.error); | |
| }); | |
| } | |
| async function idbSet(key, value) { | |
| const db = await openDB(); | |
| try { | |
| await new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE, "readwrite"); | |
| tx.objectStore(STORE).put(value, key); | |
| tx.oncomplete = () => resolve(); | |
| tx.onerror = () => reject(tx.error); | |
| }); | |
| } finally { | |
| db.close(); | |
| } | |
| } | |
| async function idbGet(key) { | |
| const db = await openDB(); | |
| try { | |
| return await new Promise((resolve, reject) => { | |
| const tx = db.transaction(STORE, "readonly"); | |
| const req = tx.objectStore(STORE).get(key); | |
| req.onsuccess = () => resolve(req.result); | |
| req.onerror = () => reject(req.error); | |
| }); | |
| } finally { | |
| db.close(); | |
| } | |
| } | |
| exports.idbSet = idbSet; | |
| exports.idbGet = idbGet; | |
| }); | |
| register("js/grid.js", function (module, exports, require) { | |
| const { state, hasEffectivePrompt, isDirty } = require("js/store.js"); | |
| /** | |
| * Virtualized thumbnail grid (spec §5.2, §9.1). Only visible rows plus a small | |
| * overscan margin are kept in the DOM. Visible cells request their thumbnail at | |
| * high priority, which naturally realizes the "viewport-first" queue of §4. | |
| * | |
| * @param {HTMLElement} scrollEl the scroll container (#grid) | |
| * @param {HTMLElement} innerEl the sized inner spacer (#gridInner) | |
| * @param {{ | |
| * loadThumb: (entry: any, priority: boolean) => Promise<string>, | |
| * onOpen: (index: number) => void | |
| * }} deps | |
| */ | |
| function createGrid(scrollEl, innerEl, { loadThumb, onOpen }) { | |
| const GAP = 12; | |
| const MIN_CELL = 180; | |
| const OVERSCAN_ROWS = 2; | |
| let cols = 1; | |
| let cellW = MIN_CELL; | |
| let rowH = MIN_CELL + GAP; | |
| /** @type {Map<number, HTMLElement>} index -> cell element */ | |
| const cellPool = new Map(); | |
| function measure() { | |
| const width = scrollEl.clientWidth - 2 * GAP; // padding on both sides | |
| cols = Math.max(1, Math.floor((width + GAP) / (MIN_CELL + GAP))); | |
| cellW = Math.floor((width - GAP * (cols - 1)) / cols); | |
| rowH = cellW + GAP; | |
| } | |
| function makeCell() { | |
| const el = document.createElement("div"); | |
| el.className = "cell"; | |
| const img = document.createElement("img"); | |
| img.className = "thumb"; | |
| img.decoding = "async"; | |
| const badge = document.createElement("span"); | |
| badge.className = "badge"; | |
| el.append(img, badge); | |
| el._img = img; | |
| el._badge = badge; | |
| el._thumbKey = null; | |
| el.addEventListener("click", () => { | |
| const idx = Number(el.dataset.index); | |
| if (!Number.isNaN(idx)) onOpen(idx); | |
| }); | |
| return el; | |
| } | |
| function positionCell(el, row, col) { | |
| el.style.width = cellW + "px"; | |
| el.style.height = cellW + "px"; | |
| el.style.transform = `translate(${col * (cellW + GAP)}px, ${row * rowH}px)`; | |
| } | |
| function fillCell(el, index) { | |
| const e = state.filtered[index]; | |
| el.dataset.index = index; | |
| el.title = e.name; | |
| const has = hasEffectivePrompt(e); | |
| el._badge.className = "badge " + (has ? "has" : "no"); | |
| el._badge.textContent = has ? "✓" : "∅"; | |
| el.classList.toggle("dirty", isDirty(e)); | |
| if (el._thumbKey !== e.thumbKey) { | |
| el._thumbKey = e.thumbKey; | |
| el._img.classList.remove("loaded"); | |
| el._img.removeAttribute("src"); | |
| loadThumb(e, true) | |
| .then((url) => { | |
| if (el._thumbKey === e.thumbKey) { | |
| el._img.src = url; | |
| el._img.classList.add("loaded"); | |
| } | |
| }) | |
| .catch(() => {}); | |
| } | |
| } | |
| function renderVisible() { | |
| const n = state.filtered.length; | |
| const scrollTop = scrollEl.scrollTop; | |
| const viewH = scrollEl.clientHeight; | |
| const firstRow = Math.max(0, Math.floor(scrollTop / rowH) - OVERSCAN_ROWS); | |
| const lastRow = Math.min( | |
| Math.ceil((scrollTop + viewH) / rowH) + OVERSCAN_ROWS, | |
| Math.ceil(n / cols) | |
| ); | |
| const needed = new Set(); | |
| for (let r = firstRow; r < lastRow; r++) { | |
| for (let c = 0; c < cols; c++) { | |
| const i = r * cols + c; | |
| if (i >= n) break; | |
| needed.add(i); | |
| let el = cellPool.get(i); | |
| if (!el) { | |
| el = makeCell(); | |
| cellPool.set(i, el); | |
| innerEl.appendChild(el); | |
| } | |
| positionCell(el, r, c); | |
| fillCell(el, i); | |
| } | |
| } | |
| for (const [i, el] of cellPool) { | |
| if (!needed.has(i)) { | |
| el.remove(); | |
| cellPool.delete(i); | |
| } | |
| } | |
| } | |
| function clearPool() { | |
| for (const el of cellPool.values()) el.remove(); | |
| cellPool.clear(); | |
| } | |
| function layout() { | |
| if (scrollEl.clientWidth === 0) return; // hidden (detail view open) | |
| const prevCols = cols; | |
| measure(); | |
| if (cols !== prevCols) clearPool(); // column count changed -> re-tile | |
| const rows = Math.ceil(state.filtered.length / cols); | |
| innerEl.style.height = rows * rowH + "px"; | |
| renderVisible(); | |
| } | |
| /** Full re-tile: call after filter/search/save changes the entry set. */ | |
| function refresh() { | |
| clearPool(); | |
| layout(); | |
| } | |
| function scrollToIndex(index) { | |
| if (index < 0) return; | |
| measure(); | |
| const row = Math.floor(index / cols); | |
| scrollEl.scrollTop = row * rowH; | |
| } | |
| // --- events --- | |
| let rafPending = false; | |
| scrollEl.addEventListener("scroll", () => { | |
| if (rafPending) return; | |
| rafPending = true; | |
| requestAnimationFrame(() => { | |
| rafPending = false; | |
| renderVisible(); | |
| }); | |
| }); | |
| const ro = new ResizeObserver(() => layout()); | |
| ro.observe(scrollEl); | |
| return { refresh, layout, scrollToIndex }; | |
| } | |
| exports.createGrid = createGrid; | |
| }); | |
| register("js/store.js", function (module, exports, require) { | |
| /** | |
| * In-memory application state (spec §2: runtime state is an in-memory array, | |
| * loaded on folder open and discarded on close). Disk stays the source of truth. | |
| */ | |
| const { SECTION_ORDER, GLOBAL_SECTIONS } = require("js/scenes.js"); | |
| const state = { | |
| /** Active top-level section: "images" | "library". */ | |
| section: "images", | |
| // ----- Image-prompts section ----- | |
| /** @type {import("./storage/fs-adapter.js").ImageEntry[]} */ | |
| entries: [], | |
| /** Current view after filter + search. */ | |
| filtered: [], | |
| /** "all" | "with" | "without" */ | |
| filterMode: "all", | |
| search: "", | |
| /** Index into `filtered` for the open detail image, or -1. */ | |
| selectedIndex: -1, | |
| /** "grid" | "detail" */ | |
| view: "grid", | |
| /** | |
| * Unsaved edits, keyed by baseName. | |
| * @type {Map<string, { text: string, base: string }>} | |
| * text = current editor value, base = disk value at edit start (conflict ref) | |
| */ | |
| dirty: new Map(), | |
| folderName: "", | |
| writable: true, | |
| /** Privacy toggle: hide the original image in the detail view. */ | |
| imageHidden: false, | |
| // ----- Prompt-library section ----- | |
| library: { | |
| /** @type {import("./storage/prompt-library-adapter.js").PromptEntry[]} */ | |
| entries: [], | |
| filtered: [], | |
| search: "", | |
| /** name of the selected prompt, or null */ | |
| selectedName: null, | |
| /** true when the editor has unsaved changes */ | |
| dirty: false, | |
| folderName: "", | |
| /** true when the master list is collapsed so the editor gets full width */ | |
| listCollapsed: false, | |
| }, | |
| }; | |
| /** Prompt as the user currently sees it: pending edit if any, else disk value. */ | |
| function effectivePrompt(entry) { | |
| const d = state.dirty.get(entry.baseName); | |
| return d ? d.text : entry.prompt; | |
| } | |
| function isDirty(entry) { | |
| return state.dirty.has(entry.baseName); | |
| } | |
| /** Whether an image counts as "having a prompt" (non-empty effective text). */ | |
| function hasEffectivePrompt(entry) { | |
| const p = effectivePrompt(entry); | |
| return !!(p && p.trim().length); | |
| } | |
| function withCount() { | |
| let n = 0; | |
| for (const e of state.entries) if (hasEffectivePrompt(e)) n++; | |
| return n; | |
| } | |
| /** Recompute `state.filtered` from the current filter mode + search text. */ | |
| function applyFilters() { | |
| const q = state.search.trim().toLowerCase(); | |
| state.filtered = state.entries.filter((e) => { | |
| const has = hasEffectivePrompt(e); | |
| if (state.filterMode === "with" && !has) return false; | |
| if (state.filterMode === "without" && has) return false; | |
| if (q) { | |
| const p = (effectivePrompt(e) || "").toLowerCase(); | |
| if (!p.includes(q)) return false; | |
| } | |
| return true; | |
| }); | |
| } | |
| /** Recompute `state.library.filtered` from the library search text. */ | |
| function applyLibraryFilters() { | |
| const lib = state.library; | |
| const q = lib.search.trim().toLowerCase(); | |
| lib.filtered = lib.entries.filter((e) => { | |
| if (!q) return true; | |
| if (e.title.toLowerCase().includes(q)) return true; | |
| if ((e.body || "").toLowerCase().includes(q)) return true; // default-type prompts | |
| if (GLOBAL_SECTIONS.some((k) => (e.globals?.[k] || "").toLowerCase().includes(q))) | |
| return true; | |
| return (e.scenes || []).some((sc) => | |
| SECTION_ORDER.some((k) => (sc[k] || "").toLowerCase().includes(q)) | |
| ); | |
| }); | |
| } | |
| exports.state = state; | |
| exports.effectivePrompt = effectivePrompt; | |
| exports.isDirty = isDirty; | |
| exports.hasEffectivePrompt = hasEffectivePrompt; | |
| exports.withCount = withCount; | |
| exports.applyFilters = applyFilters; | |
| exports.applyLibraryFilters = applyLibraryFilters; | |
| }); | |
| register("js/scenes.js", function (module, exports, require) { | |
| /** | |
| * Prompt-file format + the MiniMax H3 Ref2VA scene/section model. | |
| * | |
| * A .txt prompt file is: an optional META block (`type`, `rating`, `loras`), | |
| * then a BODY. The `type` metadata (see prompt-types.js) selects the body shape: | |
| * | |
| * - "default" (or absent): the body is the whole prompt as plain text. | |
| * - "minimax-h3-ref2va": the body is a GLOBAL block plus one or more SCENE | |
| * blocks, each with the fixed sections below. | |
| * | |
| * MiniMax scenes: the global-capable sections are `subject_definitions`, | |
| * `retention_analysis`, `overall_soundscape` and `non_diegetic_music` — the | |
| * prompt defines them once and a scene fills one in only to override it; an | |
| * empty scene value falls back to the global. `summary` and | |
| * `detailed_description` are the per-scene content sections. | |
| * | |
| * Canonical section order (clipboard copy + on-disk storage): | |
| * | |
| * subject_definitions, summary, retention_analysis, | |
| * detailed_description, overall_soundscape, non_diegetic_music | |
| * | |
| * A section left empty with no global to fall back on becomes "N/A" when | |
| * copied. Markers keep the format | |
| * hand-editable and round-trippable. Metadata is shown in the UI but never | |
| * copied. A file with no `type` (e.g. an arbitrary .txt) loads as "default". | |
| */ | |
| const { DEFAULT_TYPE, MINIMAX_TYPE, normalizeType } = require("js/prompt-types.js"); | |
| /** Every section, in canonical order. */ | |
| const SECTION_ORDER = [ | |
| "subject_definitions", | |
| "summary", | |
| "retention_analysis", | |
| "detailed_description", | |
| "overall_soundscape", | |
| "non_diegetic_music", | |
| ]; | |
| /** | |
| * Sections that have a prompt-level global with per-scene override. The audio | |
| * pair belongs here because ambience and score are usually set once for the | |
| * whole prompt; a scene overrides them only when its audio differs. | |
| */ | |
| const GLOBAL_SECTIONS = [ | |
| "subject_definitions", | |
| "retention_analysis", | |
| "overall_soundscape", | |
| "non_diegetic_music", | |
| ]; | |
| /** Per-scene content sections (these get "N/A" when left empty). */ | |
| const CONTENT_SECTIONS = ["summary", "detailed_description"]; | |
| /** Short guidance shown discreetly under each section label in the editor. */ | |
| const SECTION_HINTS = { | |
| subject_definitions: "Defines referenced content and its reference labels", | |
| summary: | |
| "Summarizes the task type, target video, and main reference relationships", | |
| retention_analysis: | |
| "Describes how referenced content is preserved, transferred, or reused", | |
| detailed_description: | |
| "Describes visuals, actions, shots, sound, and dialogue in playback order", | |
| overall_soundscape: "Summarizes ambience and physical sounds", | |
| non_diegetic_music: | |
| "Describes background music audible only to the audience", | |
| }; | |
| /** Empty placeholder used for sections left blank when copying/saving. */ | |
| const EMPTY_SECTION = "N/A"; | |
| /** Sections a scene must fill in to be considered complete. */ | |
| const REQUIRED_SECTIONS = ["summary", "detailed_description"]; | |
| /** True when a section holds real content (not blank and not the "N/A" placeholder). */ | |
| function isSectionFilled(value) { | |
| const v = (value ?? "").trim(); | |
| return v !== "" && v.toUpperCase() !== EMPTY_SECTION.toUpperCase(); | |
| } | |
| /** Required sections a scene is missing, in canonical order ([] when complete). */ | |
| function missingRequiredSections(scene) { | |
| return REQUIRED_SECTIONS.filter((k) => !isSectionFilled(scene && scene[k])); | |
| } | |
| /** Highest rating a prompt can have. Ratings run 1..MAX_RATING; 0 = unrated. */ | |
| const MAX_RATING = 5; | |
| const META_MARK = "===== META ====="; | |
| const GLOBAL_MARK = "===== GLOBAL ====="; | |
| const SCENE_MARK = "===== SCENE ====="; | |
| const metaSepRe = /^\s*=+\s*META\s*=*\s*$/i; | |
| const globalSepRe = /^\s*=+\s*GLOBAL\s*=*\s*$/i; | |
| const sceneSepRe = /^\s*=+\s*SCENE\s*=*\s*$/i; | |
| // Tolerant section header. Accepts, on its own line: the section name with or | |
| // WITHOUT a colon, optional markdown (**, #, -, >), and a space or underscore | |
| // between words ("detailed description" == "detailed_description"). A colon may | |
| // carry an inline value ("summary: text"). Without a colon it only matches when | |
| // the section name is alone on the line, so content like "summary of the day" | |
| // is not mistaken for a header. | |
| const sectionAlt = SECTION_ORDER.map((s) => s.replace(/_/g, "[ _]")).join("|"); | |
| const headerRe = new RegExp( | |
| `^\\s*(?:[*#>\\-]+\\s*)?\\**\\s*(${sectionAlt})\\**\\s*(?::\\s*(.*?))?\\s*$`, | |
| "i" | |
| ); | |
| const fenceRe = /^\s*```/; | |
| const metaLineRe = /^\s*([A-Za-z_]+)\s*:\s*(.*)$/; | |
| function emptyScene() { | |
| const s = {}; | |
| for (const k of SECTION_ORDER) s[k] = ""; | |
| return s; | |
| } | |
| function emptyGlobals() { | |
| const g = {}; | |
| for (const k of GLOBAL_SECTIONS) g[k] = ""; | |
| return g; | |
| } | |
| /** Prompt-level metadata (shown in the UI, never copied). */ | |
| function emptyMeta() { | |
| return { rating: 0, loras: [] }; | |
| } | |
| /** Coerce a value to a valid rating integer in 0..MAX_RATING (0 = unrated). */ | |
| function clampRating(v) { | |
| const r = parseInt(v, 10); | |
| return Number.isFinite(r) ? Math.min(MAX_RATING, Math.max(0, r)) : 0; | |
| } | |
| /** Split a comma-separated string into trimmed, de-duplicated, non-empty items. */ | |
| function parseList(str) { | |
| const seen = new Set(); | |
| const out = []; | |
| for (const raw of String(str ?? "").split(",")) { | |
| const v = raw.trim(); | |
| if (v && !seen.has(v)) { | |
| seen.add(v); | |
| out.push(v); | |
| } | |
| } | |
| return out; | |
| } | |
| function toScene(data) { | |
| const s = emptyScene(); | |
| for (const k of SECTION_ORDER) s[k] = typeof data[k] === "string" ? data[k] : ""; | |
| // A global-capable section holding just the "N/A" placeholder is NOT an | |
| // override: drop it so the value falls back to the global. This matters for | |
| // files written while the audio sections were still per-scene content (they | |
| // stored "N/A" when empty), and for AI drafts that answer "N/A". | |
| for (const k of GLOBAL_SECTIONS) if (!isSectionFilled(s[k])) s[k] = ""; | |
| return s; | |
| } | |
| function trimBlankEdges(lines) { | |
| let a = 0; | |
| let b = lines.length; | |
| while (a < b && lines[a].trim() === "") a++; | |
| while (b > a && lines[b - 1].trim() === "") b--; | |
| return lines.slice(a, b).join("\n"); | |
| } | |
| /** | |
| * Split an optional leading META block from the body. Meta lines are the | |
| * contiguous `key: value` lines right after the `===== META =====` marker; a | |
| * blank line (or a body marker) ends the block. Without a META marker the whole | |
| * text is the body. | |
| * @returns {{ meta: Record<string,string>, body: string }} | |
| */ | |
| function splitMeta(text) { | |
| const lines = (text ?? "").split(/\r?\n/); | |
| let i = 0; | |
| while (i < lines.length && lines[i].trim() === "") i++; | |
| if (!metaSepRe.test(lines[i] ?? "")) return { meta: {}, body: text ?? "" }; | |
| i++; // past the META marker | |
| while (i < lines.length && lines[i].trim() === "") i++; // blanks after marker | |
| const meta = {}; | |
| while (i < lines.length) { | |
| const line = lines[i]; | |
| if (line.trim() === "") { i++; break; } // a blank ends the meta block | |
| if (globalSepRe.test(line) || sceneSepRe.test(line)) break; | |
| const m = line.match(metaLineRe); | |
| if (!m) break; | |
| meta[m[1].toLowerCase()] = m[2].trim(); | |
| i++; | |
| } | |
| while (i < lines.length && lines[i].trim() === "") i++; // blanks before body | |
| return { meta, body: lines.slice(i).join("\n") }; | |
| } | |
| /** Parse a MiniMax body (GLOBAL + SCENE blocks) into globals + scenes. */ | |
| function parseMinimaxBody(text) { | |
| const lines = (text ?? "").split(/\r?\n/); | |
| const blocks = []; | |
| let block = null; | |
| let section = null; | |
| let buf = []; | |
| const flush = () => { | |
| if (block && section) block.data[section] = trimBlankEdges(buf); | |
| buf = []; | |
| }; | |
| const start = (kind) => { | |
| flush(); | |
| block = { kind, data: {} }; | |
| blocks.push(block); | |
| section = null; | |
| }; | |
| for (const line of lines) { | |
| if (fenceRe.test(line)) continue; // ignore stray ``` markdown fences | |
| if (globalSepRe.test(line)) { start("global"); continue; } | |
| if (sceneSepRe.test(line)) { start("scene"); continue; } | |
| const h = line.match(headerRe); | |
| if (h) { | |
| flush(); | |
| if (!block) start("scene"); // header before any marker -> a scene (legacy) | |
| section = h[1].toLowerCase().replace(/\s+/g, "_"); // "detailed description" -> key | |
| if (h[2] && h[2].trim()) buf.push(h[2]); // inline value on the header line | |
| continue; | |
| } | |
| if (section) buf.push(line); | |
| } | |
| flush(); | |
| const globals = emptyGlobals(); | |
| const scenes = []; | |
| for (const b of blocks) { | |
| if (b.kind === "global") { | |
| for (const k of GLOBAL_SECTIONS) { | |
| if (typeof b.data[k] === "string") globals[k] = b.data[k]; | |
| } | |
| } else { | |
| scenes.push(toScene(b.data)); | |
| } | |
| } | |
| if (scenes.length === 0) scenes.push(emptyScene()); | |
| return { globals, scenes }; | |
| } | |
| /** | |
| * True when text carries the MiniMax structure (a GLOBAL/SCENE marker or a | |
| * recognized `section:` header line). Used to guard converting a default prompt | |
| * to the MiniMax type — plain free text has none of these and is rejected. | |
| */ | |
| function looksLikeMinimax(text) { | |
| return (text ?? "") | |
| .split(/\r?\n/) | |
| .some((l) => globalSepRe.test(l) || sceneSepRe.test(l) || headerRe.test(l)); | |
| } | |
| /** | |
| * Parse a prompt file into its type + metadata + body/scenes. | |
| * @param {string} text | |
| * @returns {{ type:string, meta:{rating:number,loras:string[]}, body:string, | |
| * globals: Record<string,string>, scenes: object[] }} | |
| */ | |
| function parsePrompt(text) { | |
| const { meta: rawMeta, body } = splitMeta(text); | |
| const meta = emptyMeta(); | |
| meta.rating = clampRating(rawMeta.rating); | |
| meta.loras = parseList(rawMeta.loras); | |
| const type = normalizeType(rawMeta.type); | |
| if (type === MINIMAX_TYPE) { | |
| const { globals, scenes } = parseMinimaxBody(body); | |
| return { type, meta, body: "", globals, scenes }; | |
| } | |
| return { | |
| type: DEFAULT_TYPE, | |
| meta, | |
| body: (body ?? "").replace(/\s+$/, ""), | |
| globals: emptyGlobals(), | |
| scenes: [], | |
| }; | |
| } | |
| /** Effective value for a section: the scene's own if set, else the global. */ | |
| function effectiveSection(scene, globals, key) { | |
| if (!GLOBAL_SECTIONS.includes(key)) return scene[key] ?? ""; | |
| // "N/A" is treated as no override (see toScene), so it falls back too. | |
| return isSectionFilled(scene && scene[key]) | |
| ? scene[key] | |
| : (globals && globals[key]) || ""; | |
| } | |
| /** | |
| * One scene in the canonical clipboard format. Global-capable sections fall back | |
| * to the global value; every empty section becomes "N/A". | |
| * @param {object} scene | |
| * @param {Record<string,string>} globals | |
| */ | |
| function sceneToText(scene, globals) { | |
| const parts = []; | |
| for (const k of SECTION_ORDER) { | |
| let v = effectiveSection(scene, globals, k); | |
| if ((v ?? "").trim() === "") v = EMPTY_SECTION; | |
| parts.push(`${k}:\n${v}`); | |
| } | |
| return parts.join("\n\n"); | |
| } | |
| /** A scene block for on-disk storage: raw values in canonical order. */ | |
| function sceneFileText(scene) { | |
| return SECTION_ORDER.map((k) => `${k}:\n${scene[k] ?? ""}`).join("\n\n"); | |
| } | |
| /** Serialize just the MiniMax body (GLOBAL block + one block per scene). */ | |
| function serializeMinimaxBody(globals, scenes) { | |
| const list = scenes && scenes.length ? scenes : [emptyScene()]; | |
| const globalText = GLOBAL_SECTIONS.map( | |
| (k) => `${k}:\n${(globals && globals[k]) || ""}` | |
| ).join("\n\n"); | |
| const blocks = [`${GLOBAL_MARK}\n\n${globalText}`]; | |
| for (const s of list) blocks.push(`${SCENE_MARK}\n\n${sceneFileText(s)}`); | |
| return blocks.join("\n\n"); | |
| } | |
| /** | |
| * Full file contents for a prompt of any type. The META block is written only | |
| * when there's something to persist — for MiniMax the `type:` is always written | |
| * (so it isn't re-read as "default"); for the default type it is omitted (its | |
| * absence implies "default"), keeping such files as clean plain text. | |
| * @param {{ type:string, meta:{rating:number,loras:string[]}, body?:string, | |
| * globals?:Record<string,string>, scenes?:object[] }} prompt | |
| */ | |
| function serializePrompt({ type, meta, body, globals, scenes }) { | |
| const t = normalizeType(type); | |
| const rating = clampRating(meta && meta.rating); | |
| const loras = meta && Array.isArray(meta.loras) ? meta.loras : []; | |
| const metaLines = []; | |
| if (t !== DEFAULT_TYPE) metaLines.push(`type: ${t}`); | |
| if (rating > 0) metaLines.push(`rating: ${rating}`); | |
| if (loras.length) metaLines.push(`loras: ${loras.join(", ")}`); | |
| const blocks = []; | |
| if (metaLines.length) blocks.push(`${META_MARK}\n\n${metaLines.join("\n")}`); | |
| if (t === MINIMAX_TYPE) blocks.push(serializeMinimaxBody(globals, scenes)); | |
| else blocks.push((body ?? "").replace(/\s+$/, "")); | |
| return blocks.join("\n\n"); | |
| } | |
| /** | |
| * Return a copy of the scene where the content sections are "N/A" when empty | |
| * and the global-capable ones are blank unless they really override the global | |
| * (so a stale "N/A" from an older file is cleaned up on the next save). | |
| */ | |
| function normalizeScene(scene) { | |
| const out = {}; | |
| for (const k of SECTION_ORDER) out[k] = scene[k] ?? ""; | |
| for (const k of CONTENT_SECTIONS) { | |
| if ((out[k] ?? "").trim() === "") out[k] = EMPTY_SECTION; | |
| } | |
| for (const k of GLOBAL_SECTIONS) { | |
| if (!isSectionFilled(out[k])) out[k] = ""; | |
| } | |
| return out; | |
| } | |
| exports.SECTION_ORDER = SECTION_ORDER; | |
| exports.GLOBAL_SECTIONS = GLOBAL_SECTIONS; | |
| exports.CONTENT_SECTIONS = CONTENT_SECTIONS; | |
| exports.SECTION_HINTS = SECTION_HINTS; | |
| exports.EMPTY_SECTION = EMPTY_SECTION; | |
| exports.REQUIRED_SECTIONS = REQUIRED_SECTIONS; | |
| exports.isSectionFilled = isSectionFilled; | |
| exports.missingRequiredSections = missingRequiredSections; | |
| exports.MAX_RATING = MAX_RATING; | |
| exports.emptyScene = emptyScene; | |
| exports.emptyGlobals = emptyGlobals; | |
| exports.emptyMeta = emptyMeta; | |
| exports.clampRating = clampRating; | |
| exports.parseList = parseList; | |
| exports.parseMinimaxBody = parseMinimaxBody; | |
| exports.looksLikeMinimax = looksLikeMinimax; | |
| exports.parsePrompt = parsePrompt; | |
| exports.effectiveSection = effectiveSection; | |
| exports.sceneToText = sceneToText; | |
| exports.serializeMinimaxBody = serializeMinimaxBody; | |
| exports.serializePrompt = serializePrompt; | |
| exports.normalizeScene = normalizeScene; | |
| }); | |
| register("js/prompt-types.js", function (module, exports, require) { | |
| /** | |
| * Prompt TYPES (a.k.a. targets) for the Prompt Library. | |
| * | |
| * A prompt's type decides how it is edited, stored, and shown: | |
| * - "default" — a single free-text prompt shown in one text field. | |
| * - "minimax-h3-ref2va" — the structured scene format (subject_definitions, | |
| * summary, retention_analysis, …) specific to the | |
| * MiniMax H3 Ref2VA video model (see scenes.js). | |
| * | |
| * The type is stored as `type:` metadata in the .txt file. In its ABSENCE the | |
| * prompt is treated as "default" and the whole file is shown as plain text. | |
| * | |
| * (Not to be confused with task-types.js, which are the checkboxes inside a | |
| * MiniMax scene's summary.) | |
| */ | |
| const DEFAULT_TYPE = "default"; | |
| const MINIMAX_TYPE = "minimax-h3-ref2va"; | |
| /** Registry, in display order. */ | |
| const PROMPT_TYPES = [ | |
| { id: DEFAULT_TYPE, label: "Default (plain text)" }, | |
| { id: MINIMAX_TYPE, label: "MiniMax H3 Ref2VA" }, | |
| ]; | |
| function isKnownType(id) { | |
| return PROMPT_TYPES.some((t) => t.id === id); | |
| } | |
| /** A known type id, or DEFAULT_TYPE when missing/unknown. */ | |
| function normalizeType(id) { | |
| return isKnownType(id) ? id : DEFAULT_TYPE; | |
| } | |
| function promptTypeLabel(id) { | |
| return (PROMPT_TYPES.find((t) => t.id === id) || {}).label || id; | |
| } | |
| exports.DEFAULT_TYPE = DEFAULT_TYPE; | |
| exports.MINIMAX_TYPE = MINIMAX_TYPE; | |
| exports.PROMPT_TYPES = PROMPT_TYPES; | |
| exports.isKnownType = isKnownType; | |
| exports.normalizeType = normalizeType; | |
| exports.promptTypeLabel = promptTypeLabel; | |
| }); | |
| register("js/modal.js", function (module, exports, require) { | |
| const { toast } = require("js/toast.js"); | |
| /** | |
| * Generic modal dialog. Shows either plain text (monospace, e.g. the example | |
| * prompt) or a trusted HTML fragment (e.g. section help). Optional Copy button. | |
| */ | |
| const $ = (id) => document.getElementById(id); | |
| let el; | |
| function initModal() { | |
| el = { | |
| root: $("modal"), | |
| title: $("modalTitle"), | |
| body: $("modalBody"), | |
| copyBtn: $("modalCopyBtn"), | |
| closeBtn: $("modalCloseBtn"), | |
| }; | |
| el.closeBtn.addEventListener("click", closeModal); | |
| el.root.querySelector(".modal-backdrop").addEventListener("click", closeModal); | |
| document.addEventListener("keydown", (ev) => { | |
| if (!el.root.hidden && ev.key === "Escape") { | |
| ev.preventDefault(); | |
| ev.stopImmediatePropagation(); // when open over another modal, close only this one | |
| closeModal(); | |
| } | |
| }); | |
| } | |
| /** | |
| * @param {Object} opts | |
| * @param {string} [opts.title] | |
| * @param {string} [opts.html] trusted HTML fragment (mutually exclusive with text) | |
| * @param {string} [opts.text] plain text shown monospace/pre-wrapped | |
| * @param {string} [opts.copyText] if set, a Copy button copies this to clipboard | |
| */ | |
| function openModal({ title = "", html = null, text = null, copyText = null } = {}) { | |
| el.title.textContent = title; | |
| if (html != null) { | |
| el.body.className = "modal-body modal-html"; | |
| el.body.innerHTML = html; | |
| } else { | |
| el.body.className = "modal-body pre"; | |
| el.body.textContent = text ?? ""; | |
| } | |
| if (copyText != null) { | |
| el.copyBtn.hidden = false; | |
| el.copyBtn.onclick = async () => { | |
| try { | |
| await navigator.clipboard.writeText(copyText); | |
| toast("Copied."); | |
| } catch { | |
| toast("Clipboard blocked by the browser.", "warn"); | |
| } | |
| }; | |
| } else { | |
| el.copyBtn.hidden = true; | |
| el.copyBtn.onclick = null; | |
| } | |
| el.body.scrollTop = 0; | |
| el.root.hidden = false; | |
| } | |
| function closeModal() { | |
| if (el) el.root.hidden = true; | |
| } | |
| exports.initModal = initModal; | |
| exports.openModal = openModal; | |
| exports.closeModal = closeModal; | |
| }); | |
| register("js/toast.js", function (module, exports, require) { | |
| /** | |
| * Shared toast notifications. Non-blocking; optional action buttons keep the | |
| * toast on screen until the user chooses (used for conflict resolution). | |
| * | |
| * @param {string} message | |
| * @param {""|"warn"|"error"} [type] | |
| * @param {{label: string, onClick: () => void}[]} [actions] | |
| */ | |
| function toast(message, type = "", actions = []) { | |
| const host = document.getElementById("toasts"); | |
| if (!host) return; | |
| const t = document.createElement("div"); | |
| t.className = "toast" + (type ? " " + type : ""); | |
| const msg = document.createElement("div"); | |
| msg.textContent = message; | |
| t.appendChild(msg); | |
| if (actions.length) { | |
| const row = document.createElement("div"); | |
| row.className = "toast-actions"; | |
| for (const a of actions) { | |
| const b = document.createElement("button"); | |
| b.className = "btn"; | |
| b.textContent = a.label; | |
| b.addEventListener("click", () => { | |
| try { | |
| a.onClick(); | |
| } finally { | |
| t.remove(); | |
| } | |
| }); | |
| row.appendChild(b); | |
| } | |
| t.appendChild(row); | |
| } else { | |
| setTimeout(() => t.remove(), 4000); | |
| } | |
| host.appendChild(t); | |
| } | |
| exports.toast = toast; | |
| }); | |
| register("js/prompt-library.js", function (module, exports, require) { | |
| const { PromptLibraryAdapter } = require("js/storage/prompt-library-adapter.js"); | |
| const { idbGet, idbSet } = require("js/storage/idb.js"); | |
| const { state, applyLibraryFilters } = require("js/store.js"); | |
| const { SECTION_ORDER, GLOBAL_SECTIONS, CONTENT_SECTIONS, SECTION_HINTS, MAX_RATING, emptyScene, emptyGlobals, emptyMeta, clampRating, parseList, isSectionFilled, normalizeScene, sceneToText, missingRequiredSections, parseMinimaxBody, serializeMinimaxBody, looksLikeMinimax } = require("js/scenes.js"); | |
| const { PROMPT_TYPES, DEFAULT_TYPE, MINIMAX_TYPE, normalizeType } = require("js/prompt-types.js"); | |
| const { EXAMPLE_PROMPT } = require("js/help-example.js"); | |
| const { PROMPT_GUIDE_HTML } = require("js/prompt-guide.js"); | |
| const { SECTION_HELP } = require("js/section-help.js"); | |
| const { TASK_TYPES, TASK_TYPE_HINTS, parsePrefix, setPrefix } = require("js/task-types.js"); | |
| const { highlightHtml } = require("js/highlight.js"); | |
| const { openModal } = require("js/modal.js"); | |
| const { toast } = require("js/toast.js"); | |
| const { isLlmOnline, onLlmStatusChange } = require("js/llm/llm-config.js"); | |
| const { openTextAi } = require("js/text-ai.js"); | |
| const { openGlobalsAi } = require("js/globals-ai.js"); | |
| const { openReviewAi } = require("js/review-ai.js"); | |
| /** | |
| * Prompt Library section (spec §1.2, §3.3): manage reusable prompts stored as | |
| * one .txt per prompt in a folder of the user's choosing — separate from the | |
| * images folder. | |
| * | |
| * A prompt has a TYPE (see prompt-types.js): | |
| * - "default": a single plain-text field holding the whole prompt. | |
| * - "minimax-h3-ref2va": prompt-level GLOBAL sections (subject_definitions, | |
| * retention_analysis) plus a list of SCENES, each with all sections in | |
| * canonical order and optional per-scene overrides (see scenes.js). | |
| * | |
| * The editor swaps between a plain-text field and the scene editor based on the | |
| * type dropdown. The type can be changed to migrate; switching to MiniMax is | |
| * refused unless the current text is already in MiniMax format. | |
| * | |
| * Master list on the left; the editor on the right. Copying/previewing a MiniMax | |
| * scene yields the canonical "section:\n<text>" format with global fallback and | |
| * "N/A" for blanks. | |
| */ | |
| const adapter = new PromptLibraryAdapter(); | |
| const $ = (id) => document.getElementById(id); | |
| let el; | |
| /** @type {import("./storage/prompt-library-adapter.js").PromptEntry|null} */ | |
| let current = null; | |
| let isNew = false; | |
| let baseline = ""; | |
| /** Textareas for the prompt-level global sections, keyed by section name. */ | |
| let globalTas = {}; | |
| /** Current prompt rating shown in the editor (0 = unrated). */ | |
| let ratingValue = 0; | |
| /** The type of the prompt currently in the editor (drives which UI shows). */ | |
| let editorType = DEFAULT_TYPE; | |
| function initLibrary() { | |
| el = { | |
| openBtn: $("libOpenBtn"), | |
| reopenBtn: $("libReopenBtn"), | |
| folderName: $("libFolderName"), | |
| search: $("libSearch"), | |
| newBtn: $("libNewBtn"), | |
| counts: $("libCounts"), | |
| list: $("libList"), | |
| listToggle: $("libListToggle"), | |
| layout: $("libLayout"), | |
| editor: $("libEditor"), | |
| empty: $("libEmpty"), | |
| global: $("libGlobal"), | |
| globalHead: $("libGlobalHead"), | |
| globalGenBtn: $("libGlobalGenBtn"), | |
| scroll: $("libScroll"), | |
| title: $("libTitle"), | |
| type: $("libType"), | |
| body: $("libBody"), | |
| bodyText: $("libBodyText"), | |
| bodyGenBtn: $("libBodyGenBtn"), | |
| rating: $("libRating"), | |
| loras: $("libLorasInput"), | |
| dirty: $("libDirty"), | |
| reviewBtn: $("libReviewBtn"), | |
| previewBtn: $("libPreviewBtn"), | |
| duplicateBtn: $("libDuplicateBtn"), | |
| deleteBtn: $("libDeleteBtn"), | |
| saveBtn: $("libSaveBtn"), | |
| menu: $("libMenu"), | |
| scenes: $("libScenes"), | |
| scenesFooter: $("libScenesFooter"), | |
| addSceneBtn: $("libAddSceneBtn"), | |
| collapseAllBtn: $("libCollapseAllBtn"), | |
| meta: $("libEditMeta"), | |
| helpBtn: $("libHelpBtn"), | |
| guideBtn: $("libGuideBtn"), | |
| }; | |
| // Fill the prompt-type dropdown from the registry. | |
| for (const t of PROMPT_TYPES) { | |
| const o = document.createElement("option"); | |
| o.value = t.id; | |
| o.textContent = t.label; | |
| el.type.appendChild(o); | |
| } | |
| el.openBtn.addEventListener("click", openPicker); | |
| el.listToggle.addEventListener("click", toggleList); | |
| el.newBtn.addEventListener("click", newPrompt); | |
| el.saveBtn.addEventListener("click", save); | |
| el.duplicateBtn.addEventListener("click", duplicate); | |
| el.deleteBtn.addEventListener("click", remove); | |
| el.addSceneBtn.addEventListener("click", addScene); | |
| el.collapseAllBtn.addEventListener("click", toggleAll); | |
| el.rating.addEventListener("mouseleave", clearHot); | |
| el.loras.addEventListener("input", markDirty); | |
| el.title.addEventListener("input", markDirty); | |
| el.type.addEventListener("change", onTypeChange); | |
| el.bodyText.addEventListener("input", () => { | |
| autoGrow(el.bodyText); | |
| markDirty(); | |
| }); | |
| el.bodyGenBtn.dataset.genTitle = "Generate this prompt with AI"; | |
| el.bodyGenBtn.addEventListener("click", generateDefault); | |
| el.globalGenBtn.dataset.genTitle = "Generate subject_definitions / retention_analysis with AI"; | |
| el.globalGenBtn.addEventListener("click", generateGlobals); | |
| el.reviewBtn.dataset.genTitle = "Review the whole prompt across all scenes with AI"; | |
| el.reviewBtn.addEventListener("click", reviewPrompt); | |
| el.previewBtn.addEventListener("click", previewPrompt); | |
| // Enable/disable the "Generate" buttons with LLM connectivity. | |
| onLlmStatusChange(updateGenButtons); | |
| el.search.addEventListener("input", () => { | |
| state.library.search = el.search.value; | |
| applyLibraryFilters(); | |
| renderList(); | |
| }); | |
| // Show the full example prompt (with a Copy action). | |
| el.helpBtn.addEventListener("click", () => | |
| openModal({ | |
| title: "Example prompt", | |
| text: EXAMPLE_PROMPT, | |
| copyText: EXAMPLE_PROMPT, | |
| }) | |
| ); | |
| // Show the full prompt guide. | |
| el.guideBtn.addEventListener("click", () => | |
| openModal({ title: "Prompt guide", html: PROMPT_GUIDE_HTML }) | |
| ); | |
| // The prompt header's overflow menu is static markup; the scene ones are built | |
| // by makeActionMenu, which wires itself. | |
| wireActionMenu(el.menu); | |
| // Any click outside an open "⋮" menu dismisses it (the menu button and its | |
| // items handle their own closing). | |
| document.addEventListener("click", () => closeActionMenus()); | |
| document.addEventListener("keydown", (ev) => { | |
| if (state.section !== "library") return; | |
| const mod = ev.ctrlKey || ev.metaKey; | |
| if (mod && (ev.key === "s" || ev.key === "S")) { | |
| ev.preventDefault(); | |
| if (!el.saveBtn.disabled) save(); | |
| } | |
| if (mod && (ev.key === "b" || ev.key === "B")) { | |
| ev.preventDefault(); | |
| toggleList(); | |
| } | |
| if (ev.key === "Escape") closeActionMenus(); | |
| }); | |
| applyListCollapsed(); | |
| restore(); | |
| } | |
| /** Focus this section's search (called from the global Ctrl+F handler). */ | |
| function focusSearch() { | |
| el.search.focus(); | |
| el.search.select(); | |
| } | |
| function isDirty() { | |
| return state.library.dirty; | |
| } | |
| /** Re-measure textareas that may have been rendered while the section was hidden. */ | |
| function onShow() { | |
| regrowVisible(); | |
| } | |
| /** | |
| * Re-grow every currently-visible textarea for the active type. A textarea | |
| * measured while hidden (display:none) reports scrollHeight 0 and stays | |
| * collapsed, so ANYTHING that reveals fields must re-measure afterwards — this is | |
| * the single place that does it (called from onShow, applyTypeView and | |
| * applyListCollapsed, which changes how wide the editor is). | |
| */ | |
| function regrowVisible() { | |
| if (editorType === MINIMAX_TYPE) { | |
| for (const ta of Object.values(globalTas)) autoGrow(ta); | |
| for (const c of el.scenes.querySelectorAll(".scene-card")) { | |
| if (c.classList.contains("collapsed")) continue; | |
| for (const ta of c.querySelectorAll(".scene-section")) autoGrow(ta); | |
| } | |
| } else { | |
| autoGrow(el.bodyText); | |
| } | |
| } | |
| /** | |
| * Collapse/expand the master list (Ctrl+B) so the editor can use the whole | |
| * width — long sections are much easier to read and write wide. | |
| */ | |
| function toggleList() { | |
| state.library.listCollapsed = !state.library.listCollapsed; | |
| applyListCollapsed(); | |
| } | |
| function applyListCollapsed() { | |
| const collapsed = state.library.listCollapsed; | |
| el.layout.classList.toggle("list-collapsed", collapsed); | |
| el.listToggle.textContent = collapsed ? "⟩" : "⟨"; | |
| el.listToggle.title = collapsed | |
| ? "Show the prompt list (Ctrl+B)" | |
| : "Hide the prompt list (Ctrl+B)"; | |
| el.listToggle.setAttribute("aria-label", el.listToggle.title); | |
| el.listToggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); | |
| // The editor just changed width, so wrapped textareas need a fresh measure. | |
| regrowVisible(); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Rendering | |
| // --------------------------------------------------------------------------- | |
| function updateCounts() { | |
| const n = state.library.entries.length; | |
| el.counts.textContent = n ? `${n} prompt${n === 1 ? "" : "s"}` : ""; | |
| } | |
| function renderList() { | |
| el.list.textContent = ""; | |
| const frag = document.createDocumentFragment(); | |
| for (const e of state.library.filtered) { | |
| const item = document.createElement("div"); | |
| item.className = "lib-item"; | |
| if (!isNew && current && e.name === current.name) item.classList.add("active"); | |
| const t = document.createElement("div"); | |
| t.className = "t"; | |
| t.textContent = e.title; | |
| item.appendChild(t); | |
| const sub = document.createElement("div"); | |
| sub.className = "tags"; | |
| const type = normalizeType(e.type); | |
| if (type === MINIMAX_TYPE) { | |
| const count = e.scenes.length; | |
| sub.textContent = `${count} scene${count === 1 ? "" : "s"}`; | |
| } else { | |
| sub.textContent = "text"; | |
| } | |
| const rating = e.meta ? e.meta.rating : 0; | |
| if (rating > 0) { | |
| const stars = document.createElement("span"); | |
| stars.className = "stars"; | |
| stars.textContent = " · " + starsText(rating); | |
| sub.appendChild(stars); | |
| } | |
| const incomplete = | |
| type === MINIMAX_TYPE | |
| ? e.scenes.filter((s) => sceneMissing(s).length > 0).length | |
| : 0; | |
| if (incomplete > 0) { | |
| const flag = document.createElement("span"); | |
| flag.className = "lib-incomplete"; | |
| flag.textContent = ` · ⚠ ${incomplete} incomplete`; | |
| flag.title = `${incomplete} scene${incomplete === 1 ? "" : "s"} missing a required section or task type`; | |
| sub.appendChild(flag); | |
| } | |
| item.appendChild(sub); | |
| item.addEventListener("click", () => selectEntry(e)); | |
| frag.appendChild(item); | |
| } | |
| el.list.appendChild(frag); | |
| } | |
| function autoGrow(ta) { | |
| ta.style.height = "auto"; | |
| ta.style.height = ta.scrollHeight + "px"; | |
| } | |
| /** Word count of a textarea's value (0 for blank). */ | |
| function countWords(text) { | |
| const t = (text ?? "").trim(); | |
| return t ? t.split(/\s+/).length : 0; | |
| } | |
| /** A label row for a section, with an optional "?" help button. */ | |
| function makeLabelRow(key) { | |
| const labelRow = document.createElement("div"); | |
| labelRow.className = "scene-label-row"; | |
| const label = document.createElement("label"); | |
| label.textContent = key; | |
| labelRow.appendChild(label); | |
| if (SECTION_HELP[key]) { | |
| const help = document.createElement("button"); | |
| help.type = "button"; | |
| help.className = "section-help"; | |
| help.textContent = "?"; | |
| help.title = `About the "${key}" section`; | |
| help.addEventListener("click", () => | |
| openModal({ title: `${key} — guidance`, html: SECTION_HELP[key] }) | |
| ); | |
| labelRow.appendChild(help); | |
| } | |
| return labelRow; | |
| } | |
| /** A textarea with a highlight backdrop. `section` (or null) sets dataset. */ | |
| function makeHighlightedTextarea(section, value) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "hl-wrap"; | |
| const backdrop = document.createElement("div"); | |
| backdrop.className = "hl-backdrop"; | |
| backdrop.setAttribute("aria-hidden", "true"); | |
| backdrop.innerHTML = highlightHtml(value ?? ""); | |
| const ta = document.createElement("textarea"); | |
| ta.className = "scene-section"; | |
| if (section) ta.dataset.section = section; | |
| ta.value = value ?? ""; | |
| ta.addEventListener("input", () => { | |
| backdrop.innerHTML = highlightHtml(ta.value); | |
| autoGrow(ta); | |
| markDirty(); | |
| }); | |
| wrap.append(backdrop, ta); | |
| return { wrap, ta, backdrop }; | |
| } | |
| /** Task types currently checked in a group, in canonical table order. */ | |
| function readChosenTypes(group) { | |
| const checked = new Set(); | |
| for (const cb of group.querySelectorAll('input[type="checkbox"]')) { | |
| if (cb.checked) checked.add(cb.value); | |
| } | |
| return TASK_TYPES.filter((t) => checked.has(t)); | |
| } | |
| /** Build the task-type checkbox row bound to a summary textarea. */ | |
| function makeTaskTypes(summaryTa) { | |
| const group = document.createElement("div"); | |
| group.className = "task-types"; | |
| const grid = document.createElement("div"); | |
| grid.className = "task-types-grid"; | |
| const selected = new Set(parsePrefix(summaryTa.value).types); | |
| for (const t of TASK_TYPES) { | |
| const lab = document.createElement("label"); | |
| lab.className = "task-type"; | |
| lab.title = TASK_TYPE_HINTS[t] ?? ""; | |
| const cb = document.createElement("input"); | |
| cb.type = "checkbox"; | |
| cb.value = t; | |
| cb.checked = selected.has(t); | |
| cb.addEventListener("change", () => { | |
| summaryTa.value = setPrefix(summaryTa.value, readChosenTypes(group)); | |
| // Re-run the textarea input pipeline (backdrop highlight, autoGrow, | |
| // dirty, checkbox sync) from one place. | |
| summaryTa.dispatchEvent(new Event("input", { bubbles: true })); | |
| }); | |
| lab.append(cb, document.createTextNode(" " + t)); | |
| grid.appendChild(lab); | |
| } | |
| group.appendChild(grid); | |
| return group; | |
| } | |
| /** Reflect a manually edited summary prefix back into the checkboxes. */ | |
| function syncTaskTypes(group, summaryTa) { | |
| const selected = new Set(parsePrefix(summaryTa.value).types); | |
| for (const cb of group.querySelectorAll('input[type="checkbox"]')) { | |
| cb.checked = selected.has(cb.value); | |
| } | |
| } | |
| /** | |
| * Build the collapsible group that holds a scene's optional overrides for the | |
| * global-capable sections (GLOBAL_SECTIONS). It is hidden by default — these | |
| * fields duplicate the prompt-level globals and are rarely set per scene, so | |
| * keeping them out of the way saves vertical space. | |
| * @returns {{group: HTMLDivElement, body: HTMLDivElement, toggle: HTMLButtonElement}} | |
| */ | |
| function makeOverrideGroup() { | |
| const group = document.createElement("div"); | |
| group.className = "scene-overrides collapsed"; | |
| const toggle = document.createElement("button"); | |
| toggle.type = "button"; | |
| toggle.className = "scene-overrides-toggle"; | |
| toggle.addEventListener("click", () => | |
| setOverridesShown(group, group.classList.contains("collapsed")) | |
| ); | |
| const body = document.createElement("div"); | |
| body.className = "scene-overrides-body"; | |
| group.append(toggle, body); | |
| setOverridesShown(group, false); // initialize the collapsed-state toggle label | |
| return { group, body, toggle }; | |
| } | |
| /** Show or hide a scene's override group and refresh its toggle label. */ | |
| function setOverridesShown(group, shown) { | |
| group.classList.toggle("collapsed", !shown); | |
| updateOverrideBadge(group); | |
| // Textareas measured while hidden have 0 height; re-grow them on reveal. | |
| if (shown) { | |
| for (const ta of group.querySelectorAll(".scene-section")) autoGrow(ta); | |
| } | |
| } | |
| /** | |
| * Refresh the override group's affordance: flag the group when the scene | |
| * actually overrides a global-capable section, and name those sections in the | |
| * collapsed label — an applied override must be obvious without expanding | |
| * (collapsed it is styled green, see `.scene-overrides.has-override`). | |
| * The group body holds only the GLOBAL_SECTIONS fields, so every | |
| * `.scene-section` inside it is an override. | |
| */ | |
| function updateOverrideBadge(group) { | |
| const overridden = [...group.querySelectorAll(".scene-section")] | |
| .filter((ta) => isSectionFilled(ta.value)) | |
| .map((ta) => ta.dataset.section); | |
| group.classList.toggle("has-override", overridden.length > 0); | |
| const toggle = group.querySelector(".scene-overrides-toggle"); | |
| if (!toggle) return; | |
| const shown = !group.classList.contains("collapsed"); | |
| if (shown) { | |
| toggle.textContent = "▾ Hide scene overrides"; | |
| toggle.title = "Hide the optional per-scene overrides"; | |
| } else if (overridden.length) { | |
| toggle.textContent = "▸ Overrides · "; | |
| // The section names keep their on-disk lowercase spelling, unlike the | |
| // uppercased label around them. | |
| const names = document.createElement("span"); | |
| names.className = "scene-overrides-names"; | |
| names.textContent = overridden.join(", "); | |
| toggle.appendChild(names); | |
| toggle.title = `This scene overrides ${overridden.join(", ")} — click to show`; | |
| } else { | |
| toggle.textContent = "▸ Scene overrides (optional)"; | |
| toggle.title = | |
| "Show optional per-scene overrides for " + GLOBAL_SECTIONS.join(", "); | |
| } | |
| } | |
| /** | |
| * Mark a button as an AI action: it needs a live LLM, so it is disabled while | |
| * offline and re-enabled by updateGenButtons() (which keys off `.scene-generate` | |
| * and `dataset.genTitle`). | |
| */ | |
| function markAiAction(btn, genTitle) { | |
| btn.type = "button"; | |
| btn.classList.add("scene-generate"); // the look is the caller's business | |
| btn.dataset.genTitle = genTitle; | |
| btn.disabled = !isLlmOnline(); | |
| btn.title = isLlmOnline() | |
| ? genTitle | |
| : "AI not connected — configure it in AI settings"; | |
| return btn; | |
| } | |
| /** | |
| * Build a "⋮" overflow menu. Only the actions used while writing stay visible in | |
| * a header; the occasional ones go here, so the header holds a single row | |
| * however many actions exist. Used for the scene headers; the prompt header's | |
| * menu is the same component declared in index.html and wired with | |
| * `wireActionMenu`. | |
| * @param {{label:string, onClick:()=>void, title?:string, ai?:string, | |
| * danger?:boolean}[]} items `ai` marks an LLM action (its text is the | |
| * tooltip); `danger` styles a destructive one. | |
| */ | |
| function makeActionMenu(items) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "action-menu"; | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = "btn action-menu-btn"; | |
| btn.textContent = "⋮"; | |
| btn.title = "More actions"; | |
| btn.setAttribute("aria-haspopup", "menu"); | |
| btn.setAttribute("aria-expanded", "false"); | |
| const list = document.createElement("div"); | |
| list.className = "action-menu-list"; | |
| list.setAttribute("role", "menu"); | |
| list.hidden = true; | |
| for (const it of items) { | |
| const item = document.createElement("button"); | |
| item.type = "button"; | |
| item.className = "action-menu-item" + (it.danger ? " danger" : ""); | |
| item.setAttribute("role", "menuitem"); | |
| item.textContent = it.label; | |
| if (it.ai) markAiAction(item, it.ai); | |
| else if (it.title) item.title = it.title; | |
| item.addEventListener("click", it.onClick); | |
| list.appendChild(item); | |
| } | |
| wrap.append(btn, list); | |
| wireActionMenu(wrap); | |
| return wrap; | |
| } | |
| /** Give an "⋮" menu (built here or declared in HTML) its open/close behavior. */ | |
| function wireActionMenu(wrap) { | |
| wrap.querySelector(".action-menu-btn").addEventListener("click", (ev) => { | |
| ev.stopPropagation(); // don't let the document handler close it again | |
| const wasOpen = isActionMenuOpen(wrap); | |
| closeActionMenus(); | |
| if (!wasOpen) openActionMenu(wrap); | |
| }); | |
| // Picking any item dismisses the menu; the item's own handler does the work. | |
| wrap.querySelector(".action-menu-list").addEventListener("click", closeActionMenus); | |
| } | |
| function isActionMenuOpen(wrap) { | |
| const list = wrap.querySelector(".action-menu-list"); | |
| return !!list && !list.hidden; | |
| } | |
| /** Open one menu, dropping it upwards when its anchor sits low in the scroll area. */ | |
| function openActionMenu(wrap) { | |
| const list = wrap.querySelector(".action-menu-list"); | |
| list.hidden = false; // must be visible to measure | |
| wrap.querySelector(".action-menu-btn").setAttribute("aria-expanded", "true"); | |
| // #libScroll clips its overflow, so a menu opening down near the bottom of the | |
| // scene list would be cut off — flip it above the button instead. (Menus above | |
| // the scroll area, like the prompt header's, always have room.) | |
| const room = el.scroll.getBoundingClientRect().bottom - wrap.getBoundingClientRect().bottom; | |
| wrap.classList.toggle("flip-up", room < list.offsetHeight + 8); | |
| } | |
| /** Close every menu in the editor (at most one is open, but this stays state-free). */ | |
| function closeActionMenus() { | |
| for (const wrap of el.editor.querySelectorAll(".action-menu")) { | |
| wrap.querySelector(".action-menu-list").hidden = true; | |
| wrap.querySelector(".action-menu-btn").setAttribute("aria-expanded", "false"); | |
| wrap.classList.remove("flip-up"); | |
| } | |
| } | |
| function makeSceneCard(scene, index, total) { | |
| const card = document.createElement("div"); | |
| card.className = "scene-card"; | |
| const head = document.createElement("div"); | |
| head.className = "scene-head"; | |
| const toggle = document.createElement("button"); | |
| toggle.type = "button"; | |
| toggle.className = "scene-toggle"; | |
| toggle.title = "Collapse / expand this scene"; | |
| toggle.textContent = "▾"; | |
| toggle.addEventListener("click", () => toggleCard(card)); | |
| const title = document.createElement("span"); | |
| title.className = "scene-title"; | |
| title.textContent = `Scene ${index + 1}`; | |
| // Clicking the title also toggles, for a bigger hit target. | |
| title.addEventListener("click", () => toggleCard(card)); | |
| // Flags a scene missing a required section (summary, detailed_description) or | |
| // a task type. Kept in sync live via the card's input listener below. | |
| const status = document.createElement("span"); | |
| status.className = "scene-status"; | |
| status.hidden = true; | |
| const spacer = document.createElement("span"); | |
| spacer.className = "spacer"; | |
| // The header keeps only the actions used while writing a scene; the rest live | |
| // in the "⋮" menu so the row never wraps. | |
| const genBtn = document.createElement("button"); | |
| genBtn.className = "btn"; | |
| genBtn.textContent = "✨ Generate"; | |
| markAiAction(genBtn, "Generate a fresh scene with AI"); | |
| genBtn.addEventListener("click", () => generateScene(card, { improve: false })); | |
| const copyBtn = document.createElement("button"); | |
| copyBtn.className = "btn"; | |
| copyBtn.textContent = "📋 Copy"; | |
| copyBtn.title = "Copy this scene in section:\\n<text> format"; | |
| copyBtn.addEventListener("click", () => copyScene(card)); | |
| const upBtn = document.createElement("button"); | |
| upBtn.className = "btn scene-move"; | |
| upBtn.textContent = "↑"; | |
| upBtn.title = "Move this scene up"; | |
| upBtn.disabled = index === 0; | |
| upBtn.addEventListener("click", () => moveScene(card, -1)); | |
| const downBtn = document.createElement("button"); | |
| downBtn.className = "btn scene-move"; | |
| downBtn.textContent = "↓"; | |
| downBtn.title = "Move this scene down"; | |
| downBtn.disabled = index === total - 1; | |
| downBtn.addEventListener("click", () => moveScene(card, 1)); | |
| const menu = makeActionMenu([ | |
| { | |
| label: "Paste from clipboard", | |
| title: "Fill this scene's sections from a scene in the clipboard", | |
| onClick: () => pasteScene(card), | |
| }, | |
| { | |
| label: "Improve with AI", | |
| ai: "Improve this scene's current content with AI", | |
| onClick: () => generateScene(card, { improve: true }), | |
| }, | |
| { | |
| label: "Review with AI", | |
| ai: "Review this scene against its neighbours with AI", | |
| onClick: () => reviewScene(card), | |
| }, | |
| { | |
| label: "Preview", | |
| title: "Preview the copied result of this scene", | |
| onClick: () => previewScene(card), | |
| }, | |
| { | |
| label: "Duplicate", | |
| title: "Add a new scene below with this scene's content", | |
| onClick: () => duplicateScene(card), | |
| }, | |
| { label: "Remove", danger: true, onClick: () => removeScene(card) }, | |
| ]); | |
| head.append(toggle, title, status, spacer, genBtn, copyBtn, upBtn, downBtn, menu); | |
| card.appendChild(head); | |
| const body = document.createElement("div"); | |
| body.className = "scene-body"; | |
| // The global-capable sections are per-scene overrides that fall back to the | |
| // prompt-level globals. They live in a group that is hidden by default; the | |
| // content sections (summary, detailed_description) render inline. | |
| const { group: overrideGroup, body: overrideBody } = makeOverrideGroup(); | |
| body.appendChild(overrideGroup); | |
| for (const key of SECTION_ORDER) { | |
| const isOverride = GLOBAL_SECTIONS.includes(key); | |
| const target = isOverride ? overrideBody : body; | |
| const labelRow = makeLabelRow(key); | |
| const hint = document.createElement("p"); | |
| hint.className = "scene-hint"; | |
| hint.textContent = SECTION_HINTS[key] ?? ""; | |
| const { wrap, ta } = makeHighlightedTextarea(key, scene[key]); | |
| target.append(labelRow, hint); | |
| // Summary: task-type checkboxes after the label, just above the field. | |
| if (key === "summary") { | |
| const group = makeTaskTypes(ta); | |
| ta.addEventListener("input", () => syncTaskTypes(group, ta)); | |
| target.append(group); | |
| } | |
| // detailed_description: a live word count in the label row (the guide targets | |
| // ~350–500 words for this section). | |
| if (key === "detailed_description") { | |
| const count = document.createElement("span"); | |
| count.className = "word-count"; | |
| count.title = "Word count — the guide targets ~350–500 words for detailed_description"; | |
| const updateCount = () => { | |
| const n = countWords(ta.value); | |
| count.textContent = `${n} word${n === 1 ? "" : "s"}`; | |
| }; | |
| updateCount(); | |
| ta.addEventListener("input", updateCount); | |
| labelRow.appendChild(count); | |
| } | |
| target.append(wrap); | |
| } | |
| // Per-scene override generation: same flow as the prompt-level globals, but | |
| // targeting this scene's override fields. Lives at the top of the group body. | |
| const ovGenBtn = document.createElement("button"); | |
| ovGenBtn.type = "button"; | |
| ovGenBtn.className = "btn scene-generate scene-override-gen"; | |
| ovGenBtn.textContent = "✨ Generate"; | |
| ovGenBtn.dataset.genTitle = "Generate this scene's override subject_definitions / retention_analysis with AI"; | |
| ovGenBtn.disabled = !isLlmOnline(); | |
| ovGenBtn.title = isLlmOnline() ? ovGenBtn.dataset.genTitle : "AI not connected — configure it in AI settings"; | |
| ovGenBtn.addEventListener("click", () => generateSceneOverride(card)); | |
| overrideBody.insertBefore(ovGenBtn, overrideBody.firstChild); | |
| // Reveal the override group up front when a scene already customizes one of | |
| // those sections, so existing overrides are never hidden from the user. | |
| const hasOverride = GLOBAL_SECTIONS.some((k) => isSectionFilled(scene[k])); | |
| if (hasOverride) setOverridesShown(overrideGroup, true); | |
| card.appendChild(body); | |
| // Keep the "incomplete" flag and the override marker current: input events | |
| // from any field bubble here. | |
| card.addEventListener("input", () => { | |
| updateSceneStatus(card); | |
| updateOverrideBadge(overrideGroup); | |
| }); | |
| updateSceneStatus(card); | |
| return card; | |
| } | |
| /** | |
| * What a scene is missing to be complete: the required sections, plus at least | |
| * one task type in the summary (the [...] prefix). Returns [] when complete. | |
| */ | |
| function sceneMissing(scene) { | |
| const missing = missingRequiredSections(scene); | |
| if (parsePrefix(scene.summary || "").types.length === 0) missing.push("task type"); | |
| return missing; | |
| } | |
| /** Flag a scene card when it is missing anything required. */ | |
| function updateSceneStatus(card) { | |
| const missing = sceneMissing(sceneFromCard(card)); | |
| card.classList.toggle("incomplete", missing.length > 0); | |
| const badge = card.querySelector(".scene-status"); | |
| if (!badge) return; | |
| badge.hidden = missing.length === 0; | |
| badge.textContent = missing.length ? "⚠ Incomplete" : ""; | |
| badge.title = missing.length ? "Missing: " + missing.join(", ") : ""; | |
| } | |
| /** Collapse or expand one scene card. Re-grows textareas when expanding. */ | |
| function setCollapsed(card, collapsed) { | |
| card.classList.toggle("collapsed", collapsed); | |
| const toggle = card.querySelector(".scene-toggle"); | |
| if (toggle) toggle.textContent = collapsed ? "▸" : "▾"; | |
| if (!collapsed) { | |
| for (const ta of card.querySelectorAll(".scene-section")) autoGrow(ta); | |
| } | |
| } | |
| /** Toggle a single card (from its caret or title) and refresh the "all" label. */ | |
| function toggleCard(card) { | |
| setCollapsed(card, !card.classList.contains("collapsed")); | |
| updateCollapseAllLabel(); | |
| } | |
| /** Collapse everything if anything is expanded; otherwise expand everything. */ | |
| function toggleAll() { | |
| const cards = [...el.scenes.querySelectorAll(".scene-card")]; | |
| if (cards.length === 0) return; | |
| const anyExpanded = cards.some((c) => !c.classList.contains("collapsed")); | |
| for (const c of cards) setCollapsed(c, anyExpanded); | |
| updateCollapseAllLabel(); | |
| } | |
| /** Keep the "Collapse all"/"Expand all" button label in sync with the cards. */ | |
| function updateCollapseAllLabel() { | |
| const cards = [...el.scenes.querySelectorAll(".scene-card")]; | |
| const allCollapsed = | |
| cards.length > 0 && cards.every((c) => c.classList.contains("collapsed")); | |
| el.collapseAllBtn.textContent = allCollapsed ? "Expand all" : "Collapse all"; | |
| el.collapseAllBtn.disabled = cards.length === 0; | |
| } | |
| /** Snapshot of which scene cards are currently collapsed, by index. */ | |
| function readCollapsed() { | |
| return [...el.scenes.querySelectorAll(".scene-card")].map((c) => | |
| c.classList.contains("collapsed") | |
| ); | |
| } | |
| /** | |
| * @param {import("./scenes.js").Scene[]} scenes | |
| * @param {boolean[]} [collapsed] per-index collapsed state to restore | |
| */ | |
| function renderScenes(scenes, collapsed = []) { | |
| el.scenes.textContent = ""; | |
| const frag = document.createDocumentFragment(); | |
| const cards = scenes.map((sc, i) => makeSceneCard(sc, i, scenes.length)); | |
| cards.forEach((c) => frag.appendChild(c)); | |
| el.scenes.appendChild(frag); | |
| // Apply collapsed state, then grow only the textareas that are visible | |
| // (a collapsed card has 0-height textareas until it is expanded). | |
| cards.forEach((c, i) => { | |
| if (collapsed[i]) setCollapsed(c, true); | |
| }); | |
| for (const c of cards) { | |
| if (c.classList.contains("collapsed")) continue; | |
| for (const ta of c.querySelectorAll(".scene-section")) autoGrow(ta); | |
| } | |
| updateCollapseAllLabel(); | |
| } | |
| /** Render the prompt-level (global) sections. */ | |
| function renderGlobals(globals) { | |
| el.global.textContent = ""; | |
| globalTas = {}; | |
| for (const key of GLOBAL_SECTIONS) { | |
| const labelRow = makeLabelRow(key); | |
| const tag = document.createElement("span"); | |
| tag.className = "global-tag"; | |
| tag.textContent = "global"; | |
| labelRow.querySelector("label").after(tag); | |
| const hint = document.createElement("p"); | |
| hint.className = "scene-hint"; | |
| hint.textContent = | |
| "Shared by all scenes. A scene fills this in only to override it."; | |
| const { wrap, ta } = makeHighlightedTextarea(null, (globals && globals[key]) || ""); | |
| globalTas[key] = ta; | |
| el.global.append(labelRow, hint, wrap); | |
| autoGrow(ta); | |
| } | |
| } | |
| function readGlobals() { | |
| const g = {}; | |
| for (const key of GLOBAL_SECTIONS) g[key] = globalTas[key] ? globalTas[key].value : ""; | |
| return g; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Rating — prompt-level metadata (shown in the UI, never copied to the clipboard) | |
| // --------------------------------------------------------------------------- | |
| /** Render the 1..MAX_RATING star control for `value` (0 = unrated). */ | |
| function renderRating(value) { | |
| ratingValue = clampRating(value); | |
| el.rating.textContent = ""; | |
| for (let i = 1; i <= MAX_RATING; i++) { | |
| const star = document.createElement("button"); | |
| star.type = "button"; | |
| star.className = "star" + (i <= ratingValue ? " on" : ""); | |
| star.textContent = i <= ratingValue ? "★" : "☆"; | |
| star.title = | |
| `Rate ${i} / ${MAX_RATING}` + (ratingValue === i ? " · click again to clear" : ""); | |
| star.addEventListener("mouseenter", () => hotUpTo(i)); // hover preview | |
| // Clicking the current rating again clears it (back to unrated). | |
| star.addEventListener("click", () => setRating(ratingValue === i ? 0 : i)); | |
| el.rating.appendChild(star); | |
| } | |
| } | |
| /** Light every star up to `n` during hover (preview of the value a click sets). */ | |
| function hotUpTo(n) { | |
| el.rating.querySelectorAll(".star").forEach((s, i) => s.classList.toggle("hot", i < n)); | |
| } | |
| function clearHot() { | |
| for (const s of el.rating.querySelectorAll(".star")) s.classList.remove("hot"); | |
| } | |
| function setRating(value) { | |
| renderRating(value); | |
| markDirty(); | |
| } | |
| /** Prompt-level metadata read from the editor. */ | |
| function readMeta() { | |
| return { rating: ratingValue, loras: parseList(el.loras.value) }; | |
| } | |
| /** Read-only star string (e.g. "★★★☆☆") for a saved rating; "" when unrated. */ | |
| function starsText(rating) { | |
| const r = clampRating(rating); | |
| return r ? "★".repeat(r) + "☆".repeat(MAX_RATING - r) : ""; | |
| } | |
| /** Read the scenes currently shown in the editor, in order. */ | |
| function readScenes() { | |
| const scenes = []; | |
| for (const card of el.scenes.querySelectorAll(".scene-card")) { | |
| const scene = emptyScene(); | |
| for (const ta of card.querySelectorAll(".scene-section")) { | |
| scene[ta.dataset.section] = ta.value; | |
| } | |
| scenes.push(scene); | |
| } | |
| return scenes; | |
| } | |
| function updateMeta() { | |
| const name = isNew || !current?.name ? "(unsaved)" : current.name; | |
| if (editorType === MINIMAX_TYPE) { | |
| const n = el.scenes.querySelectorAll(".scene-card").length; | |
| el.meta.textContent = `${name} · ${n} scene${n === 1 ? "" : "s"}`; | |
| } else { | |
| el.meta.textContent = `${name} · plain text`; | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Dirty tracking | |
| // --------------------------------------------------------------------------- | |
| function snapshot() { | |
| return JSON.stringify({ | |
| t: el.title.value, | |
| ty: el.type.value, | |
| m: readMeta(), | |
| b: el.bodyText.value, | |
| g: readGlobals(), | |
| s: readScenes(), | |
| }); | |
| } | |
| function setDirty(b) { | |
| state.library.dirty = b; | |
| el.dirty.hidden = !b; | |
| el.saveBtn.disabled = !b; | |
| } | |
| function markDirty() { | |
| setDirty(isNew || snapshot() !== baseline); | |
| updateMeta(); | |
| } | |
| function setBaseline() { | |
| baseline = snapshot(); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Editor state | |
| // --------------------------------------------------------------------------- | |
| function loadIntoEditor(entry) { | |
| el.empty.hidden = true; | |
| el.editor.hidden = false; | |
| el.title.value = entry.title || ""; | |
| editorType = normalizeType(entry.type); | |
| el.type.value = editorType; | |
| renderRating(entry.meta ? entry.meta.rating : 0); | |
| el.loras.value = (entry.meta && entry.meta.loras ? entry.meta.loras : []).join(", "); | |
| if (editorType === MINIMAX_TYPE) { | |
| el.bodyText.value = ""; | |
| renderGlobals(entry.globals || emptyGlobals()); | |
| // Deep-copy scenes so edits don't mutate the stored entry until Save. | |
| renderScenes((entry.scenes && entry.scenes.length ? entry.scenes : [emptyScene()]).map((s) => ({ ...s }))); | |
| } else { | |
| el.bodyText.value = entry.body || ""; | |
| el.global.textContent = ""; | |
| globalTas = {}; | |
| el.scenes.textContent = ""; | |
| autoGrow(el.bodyText); | |
| } | |
| applyTypeView(editorType); | |
| setBaseline(); | |
| setDirty(false); | |
| updateMeta(); | |
| } | |
| /** Show the fields that belong to the current type; hide the others. */ | |
| function applyTypeView(type) { | |
| const minimax = type === MINIMAX_TYPE; | |
| el.body.hidden = minimax; | |
| el.global.hidden = !minimax; | |
| el.globalHead.hidden = !minimax; | |
| el.scenes.hidden = !minimax; | |
| el.scenesFooter.hidden = !minimax; | |
| el.reviewBtn.hidden = !minimax; // whole-prompt review only applies to MiniMax | |
| updateGenButtons(); // sync Generate buttons for the now-visible fields | |
| // Fields just became visible; re-grow any textareas measured while hidden. | |
| regrowVisible(); | |
| } | |
| /** Open the AI generator for the prompt-level globals; result fills the fields. */ | |
| function generateGlobals() { | |
| const g = readGlobals(); | |
| openGlobalsAi({ | |
| subjectDefs: g.subject_definitions || "", | |
| retention: g.retention_analysis || "", | |
| onApply: ({ subjectDefs, retention }) => { | |
| setGlobalValue("subject_definitions", subjectDefs); | |
| if ((retention ?? "").trim() !== "") setGlobalValue("retention_analysis", retention); | |
| }, | |
| }); | |
| } | |
| /** Set a global textarea's value and run its input pipeline (highlight/grow/dirty). */ | |
| function setGlobalValue(key, value) { | |
| const ta = globalTas[key]; | |
| if (!ta) return; | |
| ta.value = value; | |
| ta.dispatchEvent(new Event("input", { bubbles: true })); | |
| } | |
| /** Open the globals generator scoped to a single scene's override fields. */ | |
| function generateSceneOverride(card) { | |
| const subjTa = card.querySelector('.scene-section[data-section="subject_definitions"]'); | |
| const retTa = card.querySelector('.scene-section[data-section="retention_analysis"]'); | |
| openGlobalsAi({ | |
| subjectDefs: subjTa ? subjTa.value : "", | |
| retention: retTa ? retTa.value : "", | |
| onApply: ({ subjectDefs, retention }) => { | |
| if (subjTa) { | |
| subjTa.value = subjectDefs; | |
| subjTa.dispatchEvent(new Event("input", { bubbles: true })); | |
| } | |
| if (retTa) { | |
| retTa.value = retention; | |
| retTa.dispatchEvent(new Event("input", { bubbles: true })); | |
| } | |
| const group = card.querySelector(".scene-overrides"); | |
| if (group) setOverridesShown(group, true); // keep the applied overrides visible | |
| }, | |
| }); | |
| } | |
| function clearEditor() { | |
| current = null; | |
| isNew = false; | |
| editorType = DEFAULT_TYPE; | |
| globalTas = {}; | |
| el.global.textContent = ""; | |
| el.scenes.textContent = ""; | |
| el.bodyText.value = ""; | |
| el.editor.hidden = true; | |
| el.empty.hidden = false; | |
| el.empty.dataset.mode = adapter.dirHandle ? "select" : "nofolder"; | |
| setDirty(false); | |
| } | |
| function maybeDiscard() { | |
| if (!state.library.dirty) return true; | |
| return window.confirm("Discard unsaved changes to this prompt?"); | |
| } | |
| function selectEntry(entry) { | |
| if (current && entry.name === current.name && !isNew) return; | |
| if (!maybeDiscard()) return; | |
| current = entry; | |
| isNew = false; | |
| state.library.selectedName = entry.name; | |
| loadIntoEditor(entry); | |
| renderList(); | |
| } | |
| function newPrompt() { | |
| if (!adapter.dirHandle) { | |
| toast("Open a library folder first.", "warn"); | |
| return; | |
| } | |
| if (!maybeDiscard()) return; | |
| current = { | |
| name: "", | |
| title: "", | |
| mtime: 0, | |
| type: DEFAULT_TYPE, // new prompts start as plain text; switchable to MiniMax | |
| meta: emptyMeta(), | |
| body: "", | |
| globals: emptyGlobals(), | |
| scenes: [], | |
| _handle: null, | |
| }; | |
| isNew = true; | |
| state.library.selectedName = null; | |
| loadIntoEditor(current); | |
| setDirty(true); // a brand-new prompt is unsaved by definition | |
| renderList(); | |
| el.title.focus(); | |
| } | |
| /** | |
| * Switch the prompt type. Converting default -> MiniMax requires the current | |
| * text to be in MiniMax format (otherwise it fails and reverts). MiniMax -> | |
| * default flattens the scenes/globals into editable plain text. | |
| */ | |
| function onTypeChange() { | |
| const newType = el.type.value; | |
| if (newType === editorType) return; | |
| if (newType === MINIMAX_TYPE) { | |
| const body = el.bodyText.value; | |
| // Empty text -> start a fresh MiniMax scene. Only reject non-empty text | |
| // that isn't already in MiniMax format (nothing safe to migrate). | |
| if (body.trim() && !looksLikeMinimax(body)) { | |
| toast( | |
| "This text isn't in MiniMax H3 Ref2VA format (no scene/section structure), so it can't be converted.", | |
| "error" | |
| ); | |
| el.type.value = editorType; // revert the dropdown | |
| return; | |
| } | |
| const { globals, scenes } = parseMinimaxBody(body); | |
| editorType = MINIMAX_TYPE; | |
| el.bodyText.value = ""; | |
| renderGlobals(globals); | |
| renderScenes(scenes); | |
| } else { | |
| // Flatten the structured prompt into plain text (lossless + re-parseable). | |
| const body = serializeMinimaxBody(readGlobals(), readScenes().map(normalizeScene)); | |
| editorType = DEFAULT_TYPE; | |
| el.bodyText.value = body; | |
| el.global.textContent = ""; | |
| globalTas = {}; | |
| el.scenes.textContent = ""; | |
| autoGrow(el.bodyText); | |
| } | |
| applyTypeView(editorType); | |
| updateCollapseAllLabel(); | |
| updateMeta(); | |
| markDirty(); | |
| } | |
| /** A unique "<base> copy" title that doesn't collide with existing prompts. */ | |
| function suggestCopyTitle(base) { | |
| const titles = new Set(state.library.entries.map((e) => e.title)); | |
| const first = `${base} copy`; | |
| if (!titles.has(first)) return first; | |
| let n = 2; | |
| while (titles.has(`${base} copy ${n}`)) n++; | |
| return `${base} copy ${n}`; | |
| } | |
| /** | |
| * Duplicate the currently open prompt (its scenes as shown in the editor) into | |
| * a new, unsaved prompt with a suggested unique title. Nothing is written until | |
| * the user presses Save. | |
| */ | |
| function duplicate() { | |
| if (!current) return; | |
| const meta = readMeta(); | |
| const base = (el.title.value || current.title || "prompt").trim() || "prompt"; | |
| const isMinimax = editorType === MINIMAX_TYPE; | |
| current = { | |
| name: "", | |
| title: suggestCopyTitle(base), | |
| mtime: 0, | |
| type: editorType, | |
| meta, | |
| body: isMinimax ? "" : el.bodyText.value, | |
| globals: isMinimax ? readGlobals() : emptyGlobals(), | |
| scenes: isMinimax ? readScenes().map((s) => ({ ...s })) : [], | |
| _handle: null, | |
| }; | |
| isNew = true; | |
| state.library.selectedName = null; | |
| loadIntoEditor(current); | |
| setDirty(true); // it's a new, unsaved copy | |
| renderList(); | |
| el.title.focus(); | |
| el.title.select(); | |
| toast("Duplicated — review the title and Save."); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Scene actions | |
| // --------------------------------------------------------------------------- | |
| function addScene() { | |
| const scenes = readScenes(); | |
| // Collapse every existing scene so the freshly added one is the sole focus. | |
| const collapsed = scenes.map(() => true); | |
| scenes.push(emptyScene()); | |
| collapsed.push(false); // the new scene starts expanded | |
| renderScenes(scenes, collapsed); | |
| el.scroll.scrollTop = el.scroll.scrollHeight; | |
| const cards = el.scenes.querySelectorAll(".scene-card"); | |
| const last = cards[cards.length - 1]; | |
| // Focus the first visible content field (overrides start hidden). | |
| last?.querySelector('.scene-section[data-section="summary"]')?.focus(); | |
| markDirty(); | |
| } | |
| /** Move a scene up (dir -1) or down (dir +1), swapping with its neighbour. */ | |
| function moveScene(card, dir) { | |
| const cards = [...el.scenes.querySelectorAll(".scene-card")]; | |
| const idx = cards.indexOf(card); | |
| if (idx < 0) return; | |
| const to = idx + dir; | |
| if (to < 0 || to >= cards.length) return; | |
| const collapsed = readCollapsed(); | |
| const scenes = readScenes(); | |
| // Swap the scene and its collapsed state with the neighbour. | |
| [scenes[idx], scenes[to]] = [scenes[to], scenes[idx]]; | |
| [collapsed[idx], collapsed[to]] = [collapsed[to], collapsed[idx]]; | |
| renderScenes(scenes, collapsed); | |
| el.scenes.querySelectorAll(".scene-card")[to]?.scrollIntoView({ block: "nearest" }); | |
| markDirty(); | |
| } | |
| /** Append a copy of a scene at the end, using its current editor content. */ | |
| function duplicateScene(card) { | |
| const cards = [...el.scenes.querySelectorAll(".scene-card")]; | |
| const idx = cards.indexOf(card); | |
| if (idx < 0) return; | |
| const collapsed = readCollapsed(); | |
| const scenes = readScenes(); | |
| scenes.push({ ...scenes[idx] }); // copy of the scene's sections, appended last | |
| collapsed.push(false); // the new copy starts expanded | |
| renderScenes(scenes, collapsed); | |
| el.scroll.scrollTop = el.scroll.scrollHeight; | |
| const newCard = el.scenes.querySelector(".scene-card:last-child"); | |
| newCard?.querySelector('.scene-section[data-section="summary"]')?.focus(); | |
| markDirty(); | |
| toast("Scene duplicated."); | |
| } | |
| /** Non-empty sections of a scene as canonical text, for LLM context. */ | |
| function sceneContextText(scene) { | |
| return SECTION_ORDER.filter((k) => (scene[k] ?? "").trim() !== "") | |
| .map((k) => `${k}:\n${scene[k].trim()}`) | |
| .join("\n\n"); | |
| } | |
| /** Text of a section only when it holds real content (not blank/"N/A"). */ | |
| function filledSection(v) { | |
| const t = (v ?? "").trim(); | |
| return t && t.toUpperCase() !== "N/A" ? t : ""; | |
| } | |
| /** True when a parsed scene has at least one recognized section with content. */ | |
| function sceneHasContent(scene) { | |
| return !!scene && SECTION_ORDER.some((k) => filledSection(scene[k])); | |
| } | |
| /** Non-empty CONTENT sections of a scene as canonical text (excludes globals). */ | |
| function contentContextText(scene) { | |
| return CONTENT_SECTIONS.filter((k) => filledSection(scene[k])) | |
| .map((k) => `${k}:\n${scene[k].trim()}`) | |
| .join("\n\n"); | |
| } | |
| /** | |
| * Open the AI generator for a scene. | |
| * @param {HTMLElement} card | |
| * @param {{ improve?: boolean }} [opts] improve=true refines the scene's current | |
| * content; otherwise it's a fresh generation (no current content fed back, so | |
| * retrying gives a different take). | |
| */ | |
| function generateScene(card, { improve = false } = {}) { | |
| const cards = [...el.scenes.querySelectorAll(".scene-card")]; | |
| const idx = cards.indexOf(card); | |
| const scene = sceneFromCard(card); | |
| const globals = readGlobals(); | |
| // Always give the model the defined subjects so it reuses the <Subject N> | |
| // labels and never invents appearance. | |
| const subjects = | |
| filledSection(scene.subject_definitions) || | |
| filledSection(globals.subject_definitions); | |
| const opts = { | |
| purpose: "scene.generate", | |
| title: improve ? "Improve scene with AI" : "Generate scene with AI", | |
| // Shown read-only in the modal AND sent as context, so you can see the | |
| // subject_definitions the model will use. | |
| reference: subjects | |
| ? { label: "subject_definitions (applied)", text: subjects } | |
| : undefined, | |
| resultHint: "(editable · canonical scene format)", | |
| acceptLabel: "Use as scene", | |
| // Only allow applying when the result parses into at least one known section. | |
| validate: (text) => sceneHasContent(parseMinimaxBody(text).scenes[0]), | |
| onApply: (text) => applySceneToCard(card, parseMinimaxBody(text).scenes[0] || {}), | |
| }; | |
| if (improve) { | |
| // Refine the scene's current content. | |
| opts.context = contentContextText(scene); | |
| opts.contextLabel = "current scene content to improve/complete"; | |
| } else if (idx > 0) { | |
| // Fresh generation of scene 2+: offer to continue from the previous scene | |
| // (its detailed description, or its summary as a fallback) for continuity. | |
| const prev = sceneFromCard(cards[idx - 1]); | |
| const prevContext = | |
| filledSection(prev.detailed_description) || filledSection(prev.summary); | |
| if (prevContext) { | |
| opts.continuation = { | |
| label: `Continue from the previous scene (Scene ${idx})`, | |
| contextLabel: | |
| "PREVIOUS scene (continuity reference ONLY — keep the same subjects, setting and state; do NOT reuse its wording, shots, events or actions — the new scene must show DIFFERENT action per the TASK)", | |
| context: prevContext, | |
| }; | |
| } | |
| } | |
| openTextAi(opts); | |
| } | |
| /** Open the AI generator for the default plain-text prompt; fills the body field. */ | |
| function generateDefault() { | |
| openTextAi({ | |
| purpose: "library.default.generate", | |
| title: "Generate prompt with AI", | |
| context: el.bodyText.value, | |
| contextLabel: "Current prompt to improve/complete", | |
| resultHint: "(editable · plain text)", | |
| acceptLabel: "Use as prompt", | |
| onApply: (text) => { | |
| el.bodyText.value = text; | |
| el.bodyText.dispatchEvent(new Event("input", { bubbles: true })); | |
| el.bodyText.focus(); | |
| }, | |
| }); | |
| } | |
| /** | |
| * Assemble the whole-prompt context for an AI review: the shared | |
| * subject_definitions plus, per scene, its summary and detailed_description (the | |
| * "essentials" for a cross-scene consistency pass). A scene that overrides | |
| * subject_definitions has that override noted so mismatches surface. Empty | |
| * content is sent as "N/A" so the reviewer can flag it. | |
| */ | |
| function reviewContextText() { | |
| const globals = readGlobals(); | |
| const scenes = readScenes(); | |
| const parts = [ | |
| `subject_definitions:\n${filledSection(globals.subject_definitions) || "N/A"}`, | |
| ]; | |
| scenes.forEach((s, i) => { | |
| const block = [`===== SCENE ${i + 1} =====`]; | |
| const override = filledSection(s.subject_definitions); | |
| if (override) block.push(`subject_definitions (scene override):\n${override}`); | |
| block.push(`summary:\n${filledSection(s.summary) || "N/A"}`); | |
| block.push(`detailed_description:\n${filledSection(s.detailed_description) || "N/A"}`); | |
| parts.push(block.join("\n\n")); | |
| }); | |
| return parts.join("\n\n"); | |
| } | |
| /** Open the whole-prompt reviewer (MiniMax only). Advisory report — nothing is applied. */ | |
| function reviewPrompt() { | |
| openReviewAi({ | |
| purpose: "library.h3.review", | |
| title: "Review prompt with AI", | |
| contextLabel: | |
| "Prompt under review (read-only — subject_definitions + each scene's summary & detailed_description)", | |
| context: reviewContextText(), | |
| }); | |
| } | |
| /** | |
| * Assemble the context for a single-scene review: the globals plus the current | |
| * scene and its immediate neighbours (summary + detailed_description each). The | |
| * CURRENT scene is marked explicitly so the reviewer/rewriter target only it. | |
| */ | |
| function sceneReviewContextText(cards, idx) { | |
| const globals = readGlobals(); | |
| const cur = sceneFromCard(cards[idx]); | |
| const subj = | |
| filledSection(cur.subject_definitions) || | |
| filledSection(globals.subject_definitions); | |
| const essentials = (s) => | |
| `summary:\n${filledSection(s.summary) || "N/A"}\n\n` + | |
| `detailed_description:\n${filledSection(s.detailed_description) || "N/A"}`; | |
| const parts = [`subject_definitions:\n${subj || "N/A"}`]; | |
| if (idx > 0) { | |
| parts.push( | |
| `===== PREVIOUS SCENE (Scene ${idx}) — context only =====\n\n${essentials(sceneFromCard(cards[idx - 1]))}` | |
| ); | |
| } | |
| parts.push( | |
| `===== CURRENT SCENE (Scene ${idx + 1}) — the one under review =====\n\n${essentials(cur)}` | |
| ); | |
| if (idx < cards.length - 1) { | |
| parts.push( | |
| `===== NEXT SCENE (Scene ${idx + 2}) — context only =====\n\n${essentials(sceneFromCard(cards[idx + 1]))}` | |
| ); | |
| } | |
| return parts.join("\n\n"); | |
| } | |
| /** | |
| * Open the single-scene reviewer for a card. Advisory report, plus "Apply | |
| * suggestions" which rewrites the scene's summary + detailed_description for the | |
| * user to confirm before it is applied to the card (audio/overrides untouched). | |
| */ | |
| function reviewScene(card) { | |
| const cards = [...el.scenes.querySelectorAll(".scene-card")]; | |
| const idx = cards.indexOf(card); | |
| if (idx < 0) return; | |
| const n = idx + 1; | |
| openReviewAi({ | |
| purpose: "library.h3.scene-review", | |
| title: `Review scene ${n} with AI`, | |
| contextLabel: `Scene ${n} + neighbours (read-only)`, | |
| context: sceneReviewContextText(cards, idx), | |
| rewrite: { | |
| purpose: "scene.generate", | |
| instruction: | |
| `Rewrite ONLY the CURRENT scene (Scene ${n}), applying the review notes above. Keep the same <Subject N>/<Picture N> labels, subjects, setting, wardrobe and continuity with the previous and next scenes; introduce no new labels and no contradictions. Output ONLY these two sections in canonical format and nothing else:\n\nsummary:\n<text>\n\ndetailed_description:\n<text>`, | |
| acceptLabel: `Accept into scene ${n}`, | |
| validate: (text) => { | |
| const s = parseMinimaxBody(text).scenes[0] || {}; | |
| return !!(filledSection(s.summary) || filledSection(s.detailed_description)); | |
| }, | |
| onApply: (text) => { | |
| const s = parseMinimaxBody(text).scenes[0] || {}; | |
| applySceneToCard(card, { | |
| summary: s.summary ?? "", | |
| detailed_description: s.detailed_description ?? "", | |
| }); | |
| toast(`Scene ${n} updated — review and save.`); | |
| }, | |
| }, | |
| }); | |
| } | |
| /** | |
| * Fill a scene card from another scene (only non-empty sections overwrite). | |
| * @param {HTMLElement} card | |
| * @param {object} scene | |
| * @param {{ trustPrefix?: boolean }} [opts] `trustPrefix` takes the summary's | |
| * task-type prefix from `scene` when it has one — true for a paste (the user | |
| * copied that prefix), false for AI output (the task type is not the model's | |
| * call). | |
| */ | |
| function applySceneToCard(card, scene, { trustPrefix = false } = {}) { | |
| let overrideSet = false; | |
| for (const ta of card.querySelectorAll(".scene-section")) { | |
| const key = ta.dataset.section; | |
| const val = (scene[key] ?? "").trim(); | |
| if (val === "") continue; // don't wipe existing fields with blanks | |
| if (key === "summary") { | |
| // The task type is the user's choice: the card's own checkboxes win unless | |
| // the source is trusted and carries a prefix of its own. | |
| const source = parsePrefix(scene[key]); | |
| const own = parsePrefix(ta.value).types; | |
| const types = trustPrefix && source.types.length ? source.types : own; | |
| ta.value = setPrefix(source.rest, types); | |
| } else { | |
| ta.value = scene[key]; | |
| } | |
| ta.dispatchEvent(new Event("input", { bubbles: true })); // highlight+grow+dirty | |
| if (GLOBAL_SECTIONS.includes(key)) overrideSet = true; | |
| } | |
| if (overrideSet) { | |
| const group = card.querySelector(".scene-overrides"); | |
| if (group) setOverridesShown(group, true); | |
| } | |
| setCollapsed(card, false); // make sure the applied result is visible | |
| } | |
| /** Enable/disable every "Generate" button (scenes + default body) with LLM state. */ | |
| function updateGenButtons() { | |
| const online = isLlmOnline(); | |
| for (const b of el.editor.querySelectorAll(".scene-generate")) { | |
| b.disabled = !online; | |
| b.title = online | |
| ? b.dataset.genTitle || "Generate with AI" | |
| : "AI not connected — configure it in AI settings"; | |
| } | |
| } | |
| function removeScene(card) { | |
| const cards = [...el.scenes.querySelectorAll(".scene-card")]; | |
| const idx = cards.indexOf(card); | |
| if (idx < 0) return; | |
| if (!window.confirm(`Remove scene ${idx + 1}? This can't be undone.`)) return; | |
| const collapsed = readCollapsed(); | |
| const scenes = readScenes(); | |
| scenes.splice(idx, 1); | |
| collapsed.splice(idx, 1); | |
| if (scenes.length === 0) { | |
| scenes.push(emptyScene()); // always keep one | |
| collapsed.push(false); | |
| } | |
| renderScenes(scenes, collapsed); | |
| markDirty(); | |
| } | |
| /** Read a scene from its card's textareas. */ | |
| function sceneFromCard(card) { | |
| const scene = emptyScene(); | |
| for (const ta of card.querySelectorAll(".scene-section")) { | |
| scene[ta.dataset.section] = ta.value; | |
| } | |
| return scene; | |
| } | |
| /** Show the exact copied result of a scene (with global fallback and N/A). */ | |
| function previewScene(card) { | |
| const n = [...el.scenes.querySelectorAll(".scene-card")].indexOf(card) + 1; | |
| const text = sceneToText(sceneFromCard(card), readGlobals()); | |
| openModal({ title: `Scene ${n} preview`, text, copyText: text }); | |
| } | |
| /** | |
| * The whole MiniMax prompt as copyable text: every scene in canonical | |
| * "section:\n<value>" format (global fallback + "N/A" for blanks), each under a | |
| * "===== Scene N =====" header, in editor order. | |
| */ | |
| function minimaxPreviewText() { | |
| const globals = readGlobals(); | |
| return [...el.scenes.querySelectorAll(".scene-card")] | |
| .map( | |
| (card, i) => | |
| `===== Scene ${i + 1} =====\n\n${sceneToText(sceneFromCard(card), globals)}` | |
| ) | |
| .join("\n\n\n"); | |
| } | |
| /** | |
| * Preview (and copy) the whole prompt as it stands in the editor — every scene | |
| * for MiniMax, or the plain body for a default prompt. Reads the live editor, so | |
| * it reflects unsaved edits. | |
| */ | |
| function previewPrompt() { | |
| const text = | |
| editorType === MINIMAX_TYPE ? minimaxPreviewText() : el.bodyText.value; | |
| const title = el.title.value.trim() || (current && current.title) || "Prompt"; | |
| openModal({ title: `${title} — preview`, text, copyText: text }); | |
| } | |
| async function copyScene(card) { | |
| const scene = sceneFromCard(card); | |
| try { | |
| await navigator.clipboard.writeText(sceneToText(scene, readGlobals())); | |
| toast("Scene copied."); | |
| } catch { | |
| toast("Clipboard blocked by the browser.", "warn"); | |
| } | |
| } | |
| /** | |
| * Fill this scene from the clipboard. The clipboard may hold a whole scene or | |
| * just a few `section:` blocks (in any spelling parseMinimaxBody accepts, so | |
| * text copied out of the model's UI or hand-written works too): each section | |
| * found lands in its own field and the rest of the scene is left untouched. | |
| */ | |
| async function pasteScene(card) { | |
| let text = ""; | |
| try { | |
| text = await navigator.clipboard.readText(); | |
| } catch { | |
| toast("Clipboard blocked by the browser.", "warn"); | |
| return; | |
| } | |
| const parsedScenes = parseMinimaxBody(text).scenes; | |
| const parsed = parsedScenes[0] || {}; | |
| if (!sceneHasContent(parsed)) { | |
| toast("No scene sections found in the clipboard.", "warn"); | |
| return; | |
| } | |
| // Keep only the sections that actually carry something new. "Copy" writes the | |
| // RESOLVED sections (the global fallback already applied), so a pasted value | |
| // identical to the prompt-level global is not an override. | |
| const globals = readGlobals(); | |
| const scene = {}; | |
| for (const key of SECTION_ORDER) { | |
| const value = filledSection(parsed[key]); | |
| if (!value) continue; | |
| if (GLOBAL_SECTIONS.includes(key) && value === filledSection(globals[key])) continue; | |
| scene[key] = parsed[key]; | |
| } | |
| const keys = Object.keys(scene); | |
| if (keys.length === 0) { | |
| toast("The clipboard only repeats the global sections — nothing to paste."); | |
| return; | |
| } | |
| // Never drop written content silently: confirm when the paste replaces some. | |
| // For the summary only the text past the task-type prefix counts as written. | |
| const existing = sceneFromCard(card); | |
| const replaced = keys.filter((key) => | |
| isSectionFilled(key === "summary" ? parsePrefix(existing[key]).rest : existing[key]) | |
| ); | |
| if (replaced.length > 0) { | |
| const msg = | |
| `Replace ${replaced.length} section${replaced.length === 1 ? "" : "s"} ` + | |
| `already written in this scene?\n\n${replaced.join(", ")}`; | |
| if (!window.confirm(msg)) return; | |
| } | |
| applySceneToCard(card, scene, { trustPrefix: true }); | |
| const n = [...el.scenes.querySelectorAll(".scene-card")].indexOf(card) + 1; | |
| const extra = | |
| parsedScenes.length > 1 | |
| ? ` (first of ${parsedScenes.length} scenes in the clipboard)` | |
| : ""; | |
| toast( | |
| `Pasted ${keys.length} section${keys.length === 1 ? "" : "s"} into scene ${n}${extra}.` | |
| ); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Prompt actions | |
| // --------------------------------------------------------------------------- | |
| async function save() { | |
| if (!current) return; | |
| const title = el.title.value; | |
| if (!title.trim()) { | |
| toast("Give the prompt a title.", "warn"); | |
| el.title.focus(); | |
| return; | |
| } | |
| const meta = readMeta(); | |
| const type = editorType; | |
| // Build the per-type payload. For MiniMax, empty content sections are | |
| // persisted as "N/A"; global-capable sections stay raw so the fallback works. | |
| let payload; | |
| let scenes; | |
| let collapsed; | |
| if (type === MINIMAX_TYPE) { | |
| collapsed = readCollapsed(); | |
| scenes = readScenes().map(normalizeScene); | |
| payload = { title, type, meta, globals: readGlobals(), scenes, body: "" }; | |
| } else { | |
| payload = { title, type, meta, body: el.bodyText.value, globals: emptyGlobals(), scenes: [] }; | |
| } | |
| try { | |
| const wasNew = isNew; | |
| await adapter.save(current, payload); | |
| isNew = false; | |
| if (wasNew) state.library.entries.push(current); | |
| state.library.entries.sort((a, b) => | |
| a.title.localeCompare(b.title, undefined, { numeric: true }) | |
| ); | |
| state.library.selectedName = current.name; | |
| applyLibraryFilters(); | |
| if (type === MINIMAX_TYPE) renderScenes(scenes, collapsed); // reflect "N/A" fills | |
| setBaseline(); | |
| setDirty(false); | |
| updateMeta(); | |
| renderList(); | |
| updateCounts(); | |
| toast("Prompt saved."); | |
| } catch (err) { | |
| toast("Could not save: " + err.message, "error"); | |
| } | |
| } | |
| async function remove() { | |
| if (!current) return; | |
| if (isNew) { | |
| clearEditor(); | |
| return; | |
| } | |
| if ( | |
| !window.confirm(`Delete "${current.title}"? This removes the file from disk.`) | |
| ) { | |
| return; | |
| } | |
| try { | |
| await adapter.remove(current); | |
| const idx = state.library.entries.indexOf(current); | |
| if (idx >= 0) state.library.entries.splice(idx, 1); | |
| applyLibraryFilters(); | |
| clearEditor(); | |
| renderList(); | |
| updateCounts(); | |
| toast("Prompt deleted."); | |
| } catch (err) { | |
| toast("Could not delete: " + err.message, "error"); | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Opening the library folder | |
| // --------------------------------------------------------------------------- | |
| async function openLibrary(handle) { | |
| if (!maybeDiscard()) return; | |
| const entries = await adapter.listFolder(handle); | |
| state.library.entries = entries; | |
| state.library.folderName = handle.name; | |
| state.library.selectedName = null; | |
| state.library.search = el.search.value = ""; | |
| current = null; | |
| isNew = false; | |
| el.folderName.textContent = handle.name; | |
| el.newBtn.disabled = false; | |
| applyLibraryFilters(); | |
| clearEditor(); | |
| renderList(); | |
| updateCounts(); | |
| await idbSet("libraryFolder", handle).catch(() => {}); | |
| } | |
| async function openPicker() { | |
| try { | |
| const handle = await window.showDirectoryPicker({ mode: "readwrite" }); | |
| el.reopenBtn.hidden = true; | |
| await openLibrary(handle); | |
| } catch (err) { | |
| if (err && err.name !== "AbortError") | |
| toast("Could not open library: " + err.message, "error"); | |
| } | |
| } | |
| async function restore() { | |
| const handle = await idbGet("libraryFolder").catch(() => null); | |
| if (!handle) return; | |
| el.reopenBtn.hidden = false; | |
| el.reopenBtn.textContent = `Reopen "${handle.name}"`; | |
| el.reopenBtn.addEventListener("click", async () => { | |
| let perm = await handle.queryPermission({ mode: "readwrite" }); | |
| if (perm !== "granted") | |
| perm = await handle.requestPermission({ mode: "readwrite" }); | |
| if (perm === "granted") { | |
| el.reopenBtn.hidden = true; | |
| await openLibrary(handle); | |
| } else { | |
| toast("Permission denied.", "error"); | |
| } | |
| }); | |
| } | |
| exports.initLibrary = initLibrary; | |
| exports.focusSearch = focusSearch; | |
| exports.isDirty = isDirty; | |
| exports.onShow = onShow; | |
| }); | |
| register("js/storage/prompt-library-adapter.js", function (module, exports, require) { | |
| const { parsePrompt, serializePrompt } = require("js/scenes.js"); | |
| /** | |
| * Disk access for the standalone Prompt Library (spec §1.2, §3.3). | |
| * | |
| * A separate folder from the images folder. One `.txt` per prompt; the filename | |
| * is the title. Each file holds one or more SCENES, each with the four fixed | |
| * sections (see scenes.js). Like the image adapter, this is the only place the | |
| * library touches the File System Access API. | |
| * | |
| * @typedef {Object} PromptEntry | |
| * @property {string} name "cookie-sketch.txt" | |
| * @property {string} title "cookie-sketch" | |
| * @property {number} mtime | |
| * @property {string} type prompt type ("default" | "minimax-h3-ref2va") | |
| * @property {{rating:number, loras:string[]}} meta prompt-level metadata (shown, never copied) | |
| * @property {string} body full prompt text (used by the "default" type) | |
| * @property {Record<string,string>} globals global sections shared by scenes (minimax) | |
| * @property {import("../scenes.js").Scene[]} scenes (minimax) | |
| * @property {FileSystemFileHandle|null} _handle | |
| */ | |
| /** Make a filesystem-safe title (no separators, no leading dots). */ | |
| function sanitizeTitle(t) { | |
| return t | |
| .replace(/[\/\\:*?"<>|\x00-\x1F]/g, "") | |
| .replace(/^\.+/, "") | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| } | |
| async function atomicWrite(handle, text) { | |
| const w = await handle.createWritable(); | |
| try { | |
| await w.write(text); | |
| await w.close(); // atomic swap (spec §7.1) | |
| } catch (err) { | |
| await w.abort(); | |
| throw err; | |
| } | |
| } | |
| function makeEntry(name, handle, mtime, parsed) { | |
| return { | |
| name, | |
| title: name.replace(/\.txt$/i, ""), | |
| mtime, | |
| type: parsed.type, | |
| meta: parsed.meta, | |
| body: parsed.body, | |
| globals: parsed.globals, | |
| scenes: parsed.scenes, | |
| _handle: handle, | |
| }; | |
| } | |
| class PromptLibraryAdapter { | |
| constructor() { | |
| /** @type {FileSystemDirectoryHandle|null} */ | |
| this.dirHandle = null; | |
| } | |
| /** | |
| * @param {FileSystemDirectoryHandle} handle | |
| * @returns {Promise<PromptEntry[]>} | |
| */ | |
| async listFolder(handle) { | |
| this.dirHandle = handle; | |
| const entries = []; | |
| for await (const [name, h] of handle.entries()) { | |
| if (h.kind !== "file") continue; | |
| if (name.startsWith(".")) continue; // skip dotfiles | |
| if (!name.toLowerCase().endsWith(".txt")) continue; | |
| const f = await h.getFile(); | |
| const parsed = parsePrompt(await f.text()); | |
| entries.push(makeEntry(name, h, f.lastModified, parsed)); | |
| } | |
| entries.sort((a, b) => | |
| a.title.localeCompare(b.title, undefined, { numeric: true }) | |
| ); | |
| return entries; | |
| } | |
| /** | |
| * Persist a prompt (all its scenes). Handles first-save (create) and rename | |
| * (write new, delete old) — both user-initiated via the Save button. | |
| * @param {PromptEntry} entry mutated in place on success | |
| * @param {{ title: string, type: string, meta: {rating:number, loras:string[]}, body?: string, globals?: Record<string,string>, scenes?: import("../scenes.js").Scene[] }} fields | |
| */ | |
| async save(entry, { title, type, meta, body, globals, scenes }) { | |
| const clean = sanitizeTitle(title); | |
| if (!clean) throw new Error("Title cannot be empty."); | |
| const content = serializePrompt({ type, meta, body, globals, scenes }); | |
| const newName = clean + ".txt"; | |
| if (entry._handle && newName === entry.name) { | |
| await atomicWrite(entry._handle, content); | |
| } else { | |
| await this.#assertNoCollision(newName); | |
| const nh = await this.dirHandle.getFileHandle(newName, { create: true }); | |
| await atomicWrite(nh, content); | |
| if (entry._handle && entry.name && entry.name !== newName) { | |
| try { | |
| await this.dirHandle.removeEntry(entry.name); // complete the rename | |
| } catch { | |
| /* leaving the old file behind is safe; surface nothing */ | |
| } | |
| } | |
| entry._handle = nh; | |
| } | |
| const f = await entry._handle.getFile(); | |
| entry.name = newName; | |
| entry.title = clean; | |
| entry.mtime = f.lastModified; | |
| entry.type = type; | |
| entry.meta = meta; | |
| entry.body = body ?? ""; | |
| entry.globals = globals || {}; | |
| entry.scenes = scenes || []; | |
| return entry; | |
| } | |
| /** @param {PromptEntry} entry */ | |
| async remove(entry) { | |
| if (!entry._handle || !entry.name) return; | |
| await this.dirHandle.removeEntry(entry.name); | |
| } | |
| async #assertNoCollision(newName) { | |
| let exists = false; | |
| try { | |
| await this.dirHandle.getFileHandle(newName); | |
| exists = true; | |
| } catch (e) { | |
| if (e && e.name !== "NotFoundError") throw e; | |
| } | |
| if (exists) { | |
| throw new Error( | |
| `A prompt named "${newName.replace(/\.txt$/i, "")}" already exists.` | |
| ); | |
| } | |
| } | |
| } | |
| exports.PromptLibraryAdapter = PromptLibraryAdapter; | |
| }); | |
| register("js/help-example.js", function (module, exports, require) { | |
| /** | |
| * Reference example shown by the "Help" button in the Prompt Library. | |
| * Read-only documentation — rendered via textContent so the angle-bracket | |
| * tokens (<Subject 1>, <d>…</d>, etc.) are shown literally, not parsed as HTML. | |
| */ | |
| const EXAMPLE_PROMPT = `subject_definitions: | |
| <Subject 1> is the coffee-shop environment in <Picture 1>, featuring an exposed brick wall, an orange tufted sofa with patterned pillows, a neon sign, and a wooden coffee table. | |
| <Subject 2> is the fluffy white Samoyed in <Picture 2>, <Picture 3>, and <Picture 4>, with thick white fur, pointed ears, a dark nose, and a curved tail. | |
| <Subject 3> is the young blonde woman in <Video 1>, with long blonde hair and a light-pink button-down shirt with rolled-up sleeves. | |
| <Subject 4> is the young man in <Video 2>, with short wavy brown hair and a dark-grey hoodie with drawstrings. | |
| <Audio 1> is the voice-timbre reference for <Subject 3> (S1), containing a spoken English vocal layer. | |
| summary: | |
| [reference generation + audio reference] The target video shows <Subject 3> eating a cookie in <Subject 1>. <Subject 4> enters with <Subject 2>, which lunges toward the cookie. The three-shot exchange uses <Audio 1> as the voice-timbre reference for <Subject 3> and ends with a canned audience laugh. | |
| retention_analysis: | |
| <Subject 1> (appears in [Shot 1], [Shot 2], [Shot 3]): fully_preserved - the exposed brick wall, orange tufted sofa, patterned pillows, neon sign, and wooden coffee table are retained. | |
| <Subject 2> (appears in [Shot 1], [Shot 2]): fully_preserved - the Samoyed's thick white fur, pointed ears, dark nose, and curved tail are retained. | |
| <Subject 3> (appears in [Shot 1], [Shot 2], [Shot 3]): fully_preserved - the blonde woman's identity, long hair, and light-pink shirt are retained. | |
| <Subject 4> (appears in [Shot 1], [Shot 2]): fully_preserved - the young man's short wavy brown hair and dark-grey hoodie are retained. | |
| <Audio 1>: reference - its vocal timbre guides the dialogue delivery of <Subject 3> without copying the original signal. | |
| detailed_description: | |
| The target video uses a realistic multi-camera sitcom style with warm indoor lighting. | |
| [Shot 1] A medium shot establishes <Subject 1>, the coffee shop with its exposed brick wall, orange tufted sofa, patterned pillows, neon sign, and wooden coffee table. <Subject 3> (S1), the young woman with long blonde hair and a light-pink button-down shirt with rolled-up sleeves, sits on the sofa holding a chocolate-chip cookie. From the left, <Subject 4>, the young man with short wavy brown hair and a dark-grey hoodie with drawstrings, enters holding the leash of <Subject 2>, the thick-furred white Samoyed with pointed ears, a dark nose, and a curved tail. The dog lunges toward the cookie and pulls the leash taut. <Subject 3> (S1) jerks her hand back and, using the clear youthful voice timbre referenced from <Audio 1>, exclaims with light annoyance, <d>[English] Hey! Watch your dog!</d> She closes her lips and guards the cookie while <Subject 4> pulls the dog back. | |
| [Shot 2] At 00:03.000, the shot cuts to a close-up of <Subject 4> (S2), the young man in the dark-grey hoodie from Shot 1, sitting beside <Subject 3> on the sofa and holding <Subject 2> securely in his arms. <Subject 4> (S2) says in a casual young male voice with a playful tone and an easy conversational pace, <d>[English] He just likes cookies more than me.</d> He closes his mouth into an apologetic smile and strokes the dog's thick white fur. | |
| [Shot 3] At 00:05.000, the shot cuts to a close-up of <Subject 3> (S1), the blonde woman in the light-pink shirt from Shot 1. Her annoyance softens as she looks toward the Samoyed. <Subject 3> (S1) replies in the same clear youthful voice referenced from <Audio 1> with an amused cadence, <d>[English] Well, he has good taste at least.</d> She smiles and raises the cookie in a small toast-like gesture. A classic canned audience laugh begins immediately after the line and continues through the final frame. | |
| overall_soundscape: | |
| Soft indoor coffee-shop room tone continues throughout the scene. | |
| non_diegetic_music: | |
| N/A`; | |
| exports.EXAMPLE_PROMPT = EXAMPLE_PROMPT; | |
| }); | |
| register("js/prompt-guide.js", function (module, exports, require) { | |
| const { SECTION_HELP } = require("js/section-help.js"); | |
| const { EXAMPLE_PROMPT } = require("js/help-example.js"); | |
| const { escapeHtml } = require("js/highlight.js"); | |
| /** | |
| * The full Full-Reference Mode Rewrite Output Format Guide, as a trusted HTML | |
| * fragment for the "Prompt guide" modal. Sections 2, 3, and 5 reuse the same | |
| * per-section help fragments; the complete example (section 7) is injected from | |
| * EXAMPLE_PROMPT (escaped). Everything else is authored here with angle-bracket | |
| * tokens escaped so they render literally. | |
| */ | |
| const intro = ` | |
| <p><strong>Full-Reference Mode Rewrite Output Format Guide.</strong> This explains | |
| how rewrite outputs are organized and written in full-reference mode.</p> | |
| <p>Write all six rewrite sections in English. Preserve the original language only | |
| for dialogue and lyrics inside <code><d></code> and for text visibly present | |
| in the scene.</p> | |
| <p><strong>Description detail:</strong> Make <code>detailed_description</code> as | |
| detailed and explicit as possible. For each shot, clearly establish the current | |
| composition, subject appearance and position, environment and lighting, actions | |
| and state changes, camera movement, current sound, and the points where | |
| referenced content actually appears or takes effect. Avoid reducing the | |
| description to a plot summary or a list of reference relationships.</p> | |
| <blockquote>The basic formats for shots, camera movement, speakers, dialogue, and | |
| ordinary sound are shared with the Video Prompt Writing Guide | |
| (T2VA / I2VA / FL2VA / L2VA). This guide focuses on the reference labels, | |
| analysis sections, and format differences specific to full-reference mode.</blockquote> | |
| `; | |
| const section1 = ` | |
| <table> | |
| <thead><tr><th>Section</th><th>Purpose</th></tr></thead> | |
| <tbody> | |
| <tr><td><code>subject_definitions</code></td><td>Defines referenced content and its reference labels</td></tr> | |
| <tr><td><code>summary</code></td><td>Summarizes the task type, target video, and main reference relationships</td></tr> | |
| <tr><td><code>retention_analysis</code></td><td>Describes how referenced content is preserved, transferred, or reused</td></tr> | |
| <tr><td><code>detailed_description</code></td><td>Describes visuals, actions, shots, sound, and dialogue in playback order</td></tr> | |
| <tr><td><code>overall_soundscape</code></td><td>Summarizes ambience and physical sounds</td></tr> | |
| <tr><td><code>non_diegetic_music</code></td><td>Describes background music audible only to the audience</td></tr> | |
| </tbody> | |
| </table> | |
| `; | |
| const section6 = ` | |
| <p>The definitions of these two sound categories follow the Video Prompt Writing | |
| Guide (T2VA / I2VA / FL2VA / L2VA).</p> | |
| <p><code>overall_soundscape</code> summarizes ambience and physical sounds across | |
| the full video. Dialogue, singing, and sound events synchronized to a particular | |
| shot remain in <code>detailed_description</code>:</p> | |
| <pre class="code">overall_soundscape: Quiet indoor room tone and a low ventilation hum continue throughout the video.</pre> | |
| <p><code>non_diegetic_music</code> describes background music that the characters | |
| cannot hear and that is audible only to the audience. When music is present, state | |
| its instrumentation, tempo, and dynamic development:</p> | |
| <pre class="code">non_diegetic_music: A restrained solo-piano score at a slow tempo, with sustained low cello underneath and no swell.</pre> | |
| <p>When reference audio is used, state its copy or reference relationship only in | |
| the section that matches the audible layer: ambience and sound effects belong in | |
| <code>overall_soundscape</code>, while audience-only score belongs in | |
| <code>non_diegetic_music</code>. If the same audio provides both kinds of content, | |
| describe the corresponding relationship in each section:</p> | |
| <pre class="code">overall_soundscape: The copied ambience layer from <Audio 1> continues throughout the target video. | |
| non_diegetic_music: <Audio 2> is directly reused as the complete audience-only score.</pre> | |
| <p>Write complete dialogue and lyrics only inside <code><d></code> in | |
| <code>detailed_description</code>; do not repeat them in these two sections.</p> | |
| `; | |
| const section7 = ` | |
| <details> | |
| <summary>Show the complete example</summary> | |
| <pre class="code">${escapeHtml(EXAMPLE_PROMPT)}</pre> | |
| </details> | |
| `; | |
| const PROMPT_GUIDE_HTML = [ | |
| intro, | |
| "<h3>1. Overall structure</h3>", | |
| section1, | |
| "<h3>2. Reference labels and definitions (subject_definitions)</h3>", | |
| SECTION_HELP.subject_definitions, | |
| "<h3>3. summary</h3>", | |
| SECTION_HELP.summary, | |
| "<h3>4. retention_analysis</h3>", | |
| SECTION_HELP.retention_analysis, | |
| "<h3>5. detailed_description</h3>", | |
| SECTION_HELP.detailed_description, | |
| "<h3>6. overall_soundscape and non_diegetic_music</h3>", | |
| section6, | |
| "<h3>7. Complete example</h3>", | |
| section7, | |
| ].join("\n"); | |
| exports.PROMPT_GUIDE_HTML = PROMPT_GUIDE_HTML; | |
| }); | |
| register("js/section-help.js", function (module, exports, require) { | |
| /** | |
| * Per-section guidance shown by the "?" button next to a section label. | |
| * Values are trusted, hand-authored HTML fragments (no user input). Literal | |
| * angle-bracket tokens such as <Subject N> / <Audio 1> / <d>…</d> are escaped | |
| * as entities so they render as text rather than being parsed as HTML tags. | |
| */ | |
| const summary = ` | |
| <p>This section uses one short English paragraph to summarize the target video | |
| and its reference relationships. It begins with a square-bracketed task-type | |
| prefix:</p> | |
| <pre class="code">[reference generation] ... | |
| [video editing + reference generation + audio reuse] ...</pre> | |
| <p>Choose task types according to the actual role each reference asset plays in | |
| the target video:</p> | |
| <table> | |
| <thead> | |
| <tr><th>Task type</th><th>When to use it</th></tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td><code>keyframe completion</code></td> | |
| <td>An image serves as the target video's first frame, keyframe, last | |
| frame, edited keyframe, or another concrete frame anchor</td> | |
| </tr> | |
| <tr> | |
| <td><code>reference generation</code></td> | |
| <td>An image, video, or audio asset provides generation guidance for a | |
| character, scene, style, action, camera movement, storyboard, and so on, | |
| without serving as a concrete frame or as the source video being edited | |
| or continued</td> | |
| </tr> | |
| <tr> | |
| <td><code>video editing</code></td> | |
| <td>An existing source video is directly modified; editing an image or | |
| generating between still keyframes does not belong to this type</td> | |
| </tr> | |
| <tr> | |
| <td><code>video continuation</code></td> | |
| <td>New content continues, extends, resumes, or transitions from an | |
| existing source video</td> | |
| </tr> | |
| <tr> | |
| <td><code>audio reuse</code></td> | |
| <td>The same audio signal is reused in full or in part</td> | |
| </tr> | |
| <tr> | |
| <td><code>audio reference</code></td> | |
| <td>The audio signal is not copied directly; only its music style, timbre, | |
| dialogue or lyric content, sound-effect texture, beat, or continuity is | |
| referenced</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <p>When a task satisfies multiple relationships, combine the task types with | |
| <code> + </code> and do not repeat a type. For example, continuing from a source | |
| video while using an image as the last frame is written as | |
| <code>[video continuation + keyframe completion]</code>. Editing a source video | |
| while retaining its original audio may be written as | |
| <code>[video editing + audio reuse]</code>.</p> | |
| <p>The mere presence of video or audio does not automatically create a | |
| corresponding task type. If a reference video provides only camera movement, | |
| cuts, or rhythm, it normally belongs to <code>reference generation</code>. Use | |
| <code>video editing</code> or <code>video continuation</code> only when that | |
| video is directly edited or continued.</p> | |
| <p>When editing a source video, use <code>audio reuse</code> as well if its | |
| original audio remains audible. When continuing a source video without directly | |
| copying the audio signal, use <code>audio reference</code> if the new audio only | |
| continues the original track's audible characteristics.</p> | |
| <p>The summary uses the previously defined <code><Subject N></code>, | |
| <code><Picture N></code>, <code><Video N></code>, and | |
| <code><Audio N></code> labels to describe the main subjects, shot flow, | |
| and roles of the reference assets. Do not introduce new reference labels in this | |
| section.</p> | |
| <p>For video-editing tasks, begin the summary after the task-type prefix | |
| with:</p> | |
| <pre class="code">The target video is an edited version of <Video 1>.</pre> | |
| `; | |
| const detailed_description = ` | |
| <p>This is the main body of a full-reference rewrite. It describes visuals, | |
| actions, sound, and dialogue shot by shot in target-video playback order and | |
| inserts reference labels where they apply.</p> | |
| <h4>Basic format</h4> | |
| <p>The basic format follows the Video Prompt Writing Guide | |
| (T2VA / I2VA / FL2VA / L2VA):</p> | |
| <ul> | |
| <li>Write the body in English. Preserve the original language of dialogue, | |
| lyrics, and visible text.</li> | |
| <li><code>[Shot 1]</code> marks the opening shot and has no timestamp. Later | |
| shots use <code>[Shot N] At MM:SS.mmm, ...</code> to mark cut times.</li> | |
| <li>Write camera movement as natural English within the current shot, | |
| including movement type, amplitude, and speed when they need to be | |
| expressed.</li> | |
| <li>Give vocal sources stable <code>(S1)</code>, <code>(S2)</code>, and | |
| subsequent IDs. Write dialogue and lyrics as | |
| <code><d>[Language] ...</d></code>.</li> | |
| <li>Use <code><scenetrans></code>, <code><cutoff></code>, and the | |
| corresponding continuity descriptions for dialogue crossing a cut, speech | |
| truncated by the video ending, and continuous audio across shots.</li> | |
| </ul> | |
| <p>For complete rules and examples covering camera vocabulary, group speech, | |
| voice-over, dialogue across cuts, and visible text, see the Video Prompt Writing | |
| Guide (T2VA / I2VA / FL2VA / L2VA).</p> | |
| <h4>Full-reference mode differences</h4> | |
| <table> | |
| <thead> | |
| <tr><th>Dimension</th><th>T2VA</th><th>Full-reference mode</th></tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td>Main field</td> | |
| <td><code>integrated_multimodal_description</code></td> | |
| <td><code>detailed_description</code></td> | |
| </tr> | |
| <tr> | |
| <td>Style opening</td> | |
| <td>Written after <code>[Shot 1]</code></td> | |
| <td>Established in one or two English sentences before | |
| <code>[Shot 1]</code></td> | |
| </tr> | |
| <tr> | |
| <td>Reference information</td> | |
| <td>Does not use full-reference labels</td> | |
| <td>Inserts <code><Subject N></code>, <code><Picture N></code>, | |
| <code><Video N></code>, and <code><Audio N></code> at their | |
| first appearance and where their roles apply</td> | |
| </tr> | |
| <tr> | |
| <td>Audio relationships</td> | |
| <td>Describes the target video's own sound</td> | |
| <td>Cites <code><Audio N></code> in the corresponding shot or audio | |
| phase and states whether the signal is copied or referenced</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <p>Opening example:</p> | |
| <pre class="code">The target video is in a cinematic, literary music-video style with soft lighting and a slightly desaturated color palette. | |
| [Shot 1] The scene opens in a crowded urban street... | |
| [Shot 2] At 00:09.000, the shot cuts to an extreme close-up...</pre> | |
| <p>For generation tasks, <code>detailed_description</code> is normally 350-500 | |
| English words. Dialogue-dense content prioritizes fitting the complete spoken | |
| timeline rather than mechanically reaching a word count. Video-editing | |
| descriptions scale with the complexity of the source video and do not have to | |
| follow the generation-task range. A single shot does not automatically justify a | |
| shorter description; distribute detail across multiple shots according to their | |
| information load.</p> | |
| <h4>Using reference labels in shots</h4> | |
| <p>At the first clear appearance of an important <code><Subject N></code>, | |
| describe its referenced characteristics, position in the frame, and current | |
| action within what is actually visible in the shot. Continue using the same | |
| label in later shots without redefining what the label represents.</p> | |
| <p>Use natural phrasing for concrete frame anchors:</p> | |
| <pre class="code">the shot begins from <Picture 1> | |
| the shot's keyframe corresponds to <Picture 2> | |
| the shot ends on <Picture 3></pre> | |
| <p>When editing or continuing an original video, cite <code><Video N></code> | |
| naturally where its source state, structure, or continuation relationship | |
| applies. Cite <code><Audio N></code> in the shot or semantic phase where the | |
| audio relationship is active.</p> | |
| <h4>Speakers, audio sources, and dialogue</h4> | |
| <p>The basic speaker-ID and <code><d></code> formats follow T2VA. When a | |
| referenced subject physically speaks, retain both the visual reference label and | |
| the speaker ID:</p> | |
| <pre class="code"><Subject 2> (S1) turns toward the woman and says, <d>[English] Last summer, I went to my grandfather's house. He talked about you.</d></pre> | |
| <p><code><Subject N></code> identifies the referenced subject, while | |
| <code>(Sx)</code> identifies the actual speaker. When the subject speaks, write | |
| <code><Subject N> (Sx)</code>. If the same subject speaks off-screen, keep | |
| the same form and mark it as <code>off-screen</code>. When the speaker does not | |
| correspond to a defined subject, use a stable voice description followed by | |
| <code>(Sx)</code>.</p> | |
| <p>When verbal content is only a cue within a directly reused BGM or complete | |
| soundtrack, and no person, character, narrator, or other independent vocal source | |
| physically produces it, use <code><Audio N></code> as the audible source and | |
| do not invent an additional <code>(Sx)</code>. If a concrete person, character, | |
| narrator, or other independent vocal source produces the voice, assign and reuse | |
| <code>(Sx)</code> for that source:</p> | |
| <pre class="code">When <Audio 1> reaches the phrase <d>[English] I'm lonely lonely lonely lonely lonely I'm lonely</d>, <Subject 1> performs the corresponding hand gesture without becoming a separate speaker source.</pre> | |
| <p>When dialogue, narration, or lyrics from reference audio are directly reused, | |
| or when the input prompt explicitly requests their reperformance, preserve the | |
| exact source words and original language inside <code><d></code>. Write | |
| <code>[unclear]</code> for unintelligible spans instead of guessing or | |
| paraphrasing them. Standardize punctuation to the basic written marks needed to | |
| express the sentence, such as <code>,</code>, <code>.</code>, <code>?</code>, and | |
| <code>!</code>; remove repeated tildes, emoji, bullets, and repeated or | |
| decorative punctuation. End complete statements, questions, and exclamations with | |
| <code>.</code>, <code>?</code>, or <code>!</code> respectively before | |
| <code></d></code>.</p> | |
| <p>When only timbre, rhythm, emotion, or delivery is referenced, do not carry the | |
| original dialogue from the reference audio into the target video.</p> | |
| <p>Assign <code>(Sx)</code> once according to the order of actual vocal events in | |
| the target video. Reuse the corresponding ID at every actual vocal event in | |
| <code>detailed_description</code>; an <code><Audio N></code> definition bound | |
| to a target speaker in <code>subject_definitions</code> also reuses the same | |
| <code>(Sx)</code> but never assigns a new one independently. Do not write | |
| <code>(Sx)</code> in <code>retention_analysis</code>. Verbal cues that exist only | |
| within a directly reused BGM or complete soundtrack use | |
| <code><Audio N></code>; voices physically produced by a concrete person, | |
| character, narrator, or other independent vocal source use <code>(Sx)</code>.</p> | |
| `; | |
| const overall_soundscape = ` | |
| <p>The definitions of the two sound categories follow the Video Prompt Writing | |
| Guide (T2VA / I2VA / FL2VA / L2VA).</p> | |
| <p><code>overall_soundscape</code> summarizes ambience and physical sounds across | |
| the full video. Dialogue, singing, and sound events synchronized to a particular | |
| shot remain in <code>detailed_description</code>:</p> | |
| <pre class="code">overall_soundscape: Quiet indoor room tone and a low ventilation hum continue throughout the video.</pre> | |
| <p>When reference audio is used, state its copy or reference relationship only in | |
| the section that matches the audible layer: ambience and sound effects belong in | |
| <code>overall_soundscape</code>, while audience-only score belongs in | |
| <code>non_diegetic_music</code>. If the same audio provides both kinds of | |
| content, describe the corresponding relationship in each section:</p> | |
| <pre class="code">overall_soundscape: The copied ambience layer from <Audio 1> continues throughout the target video. | |
| non_diegetic_music: <Audio 2> is directly reused as the complete audience-only score.</pre> | |
| <p>Write complete dialogue and lyrics only inside <code><d></code> in | |
| <code>detailed_description</code>; do not repeat them in this section.</p> | |
| `; | |
| const non_diegetic_music = ` | |
| <p>The definitions of the two sound categories follow the Video Prompt Writing | |
| Guide (T2VA / I2VA / FL2VA / L2VA).</p> | |
| <p><code>non_diegetic_music</code> describes background music that the characters | |
| cannot hear and that is audible only to the audience. When music is present, | |
| state its instrumentation, tempo, and dynamic development:</p> | |
| <pre class="code">non_diegetic_music: A restrained solo-piano score at a slow tempo, with sustained low cello underneath and no swell.</pre> | |
| <p>When reference audio is used, state its copy or reference relationship only in | |
| the section that matches the audible layer: ambience and sound effects belong in | |
| <code>overall_soundscape</code>, while audience-only score belongs in | |
| <code>non_diegetic_music</code>. If the same audio provides both kinds of | |
| content, describe the corresponding relationship in each section:</p> | |
| <pre class="code">overall_soundscape: The copied ambience layer from <Audio 1> continues throughout the target video. | |
| non_diegetic_music: <Audio 2> is directly reused as the complete audience-only score.</pre> | |
| <p>Write complete dialogue and lyrics only inside <code><d></code> in | |
| <code>detailed_description</code>; do not repeat them in this section.</p> | |
| `; | |
| const subject_definitions = ` | |
| <p>Full-reference rewrites use four types of labels to identify the source and | |
| role of referenced content:</p> | |
| <table> | |
| <thead><tr><th>Label</th><th>Meaning</th></tr></thead> | |
| <tbody> | |
| <tr><td><code><Subject N></code></td><td>Visible content abstracted from reference assets that can be reused or modified in the target video</td></tr> | |
| <tr><td><code><Picture N></code></td><td>A reference image used as a concrete target frame or shot-planning anchor</td></tr> | |
| <tr><td><code><Video N></code></td><td>A reference video that provides an editing source, continuation starting point, or whole-video temporal structure</td></tr> | |
| <tr><td><code><Audio N></code></td><td>An audio signal that is copied or referenced</td></tr> | |
| </tbody> | |
| </table> | |
| <p class="note">Once a reference label is assigned to a piece of content, it keeps | |
| the same meaning across <code>subject_definitions</code>, <code>summary</code>, | |
| <code>retention_analysis</code>, <code>detailed_description</code>, and the audio | |
| sections.</p> | |
| <p><code>subject_definitions</code> defines each piece of referenced content that | |
| must be tracked separately later, such as a person, an environment, a source | |
| video's structure, or an audio track. Give each item its own line and explain | |
| what its label denotes, its reference role, and the main features to follow; name | |
| the corresponding source asset when its provenance needs to be made explicit. If | |
| <code><Picture N></code> or <code><Video N></code> only identifies the | |
| source of another referenced item and will not be analyzed or used separately | |
| later, cite it inside that item's definition without adding a separate line. | |
| <code>retention_analysis</code> records where each referenced item appears and | |
| whether it is fully preserved, partially preserved, transferred, or reused.</p> | |
| <h4>2.1 <Subject N></h4> | |
| <p><code><Subject N></code> is used for reusable visible content, including:</p> | |
| <ul> | |
| <li>People, animals, or objects</li> | |
| <li>Scenes, backgrounds, or environments</li> | |
| <li>Clothing, props, interfaces, or visual effects</li> | |
| <li>Styles, actions, expressions, or poses</li> | |
| </ul> | |
| <p>It represents a content unit that will actually be used in the target video, | |
| rather than the source file itself. One subject may be defined by multiple | |
| reference assets, and one reference asset may provide multiple subjects.</p> | |
| <pre class="code"><Subject 1> is the young woman in <Picture 1>, with long dark hair, a blue cardigan, and a thin silver necklace.</pre> | |
| <p>When the same subject comes from multiple assets, combine the sources and state | |
| what each asset provides:</p> | |
| <pre class="code"><Subject 1> is the woman whose appearance comes from <Picture 1> and whose walking motion comes from <Video 1>.</pre> | |
| <h4>2.2 <Picture N></h4> | |
| <p>Use a standalone <code><Picture N></code> when the reference image itself | |
| serves as a shot's first frame, keyframe, last frame, edited keyframe, or | |
| composition anchor:</p> | |
| <pre class="code"><Picture 2> is the first frame of [Shot 1], showing a woman seated beside a café window.</pre> | |
| <p>If an image is used only to define a character, scene, costume, or style, do | |
| not create a standalone picture entry. Instead, cite the image source inside the | |
| corresponding <code><Subject N></code> definition.</p> | |
| <p>When an image acts as a storyboard or shot-planning reference, state which | |
| shots it maps to and what planning information it provides:</p> | |
| <pre class="code"><Picture 3> is a storyboard reference for [Shot 1] and [Shot 2], defining their viewpoint, subject placement, and shot order.</pre> | |
| <h4>2.3 <Video N></h4> | |
| <p><code><Video N></code> is reserved for whole-video relationships, such as:</p> | |
| <ul> | |
| <li>Editing an original video</li> | |
| <li>Continuing from the end of an original video</li> | |
| <li>Referencing the original video's camera movement, cuts, rhythm, or temporal structure</li> | |
| </ul> | |
| <pre class="code"><Video 1> is the source video for the target video edit.</pre> | |
| <p>If a person, object, scene, action, or effect from a reference video is reused | |
| as visible content, it still belongs under <code><Subject N></code>. | |
| <code><Video N></code> identifies the asset or structural source and does not | |
| replace subject labels.</p> | |
| <h4>2.4 <Audio N></h4> | |
| <p><code><Audio N></code> represents a standalone audio asset or an enabled | |
| synchronized audio track from a reference video. Common uses include:</p> | |
| <ul> | |
| <li>Copying all or part of an audio signal</li> | |
| <li>Referencing a background-music style</li> | |
| <li>Referencing a speaker's voice timbre and delivery</li> | |
| <li>Using dialogue, lyrics, or sound effects from the original audio</li> | |
| <li>Referencing beat, rhythm, or audio continuity</li> | |
| </ul> | |
| <p>When an <code><Audio N></code> explicitly corresponds to a target speaker, | |
| reuse that speaker's global ID in the definition: write | |
| <code><Subject N> (Sx)</code> when the speaker maps to a defined subject, or | |
| use a stable voice description followed by <code>(Sx)</code> otherwise. The ID | |
| comes from the target video's global speaker order and is not independently | |
| assigned or renumbered in the audio definition. See Section 5.4 for the | |
| speaker-numbering rules:</p> | |
| <pre class="code"><Audio 1> is the voice-timbre reference for <Subject 1> (S1).</pre> | |
| <p>When one audio asset serves multiple roles, describe those roles in one natural | |
| sentence rather than creating additional subsections.</p> | |
| <h4>2.5 Visual and Audio Tracks from the Same Reference Video</h4> | |
| <p><code><Video N></code> and <code><Audio N></code> are numbered | |
| independently. Each index indicates only the label's order within its own | |
| category and does not encode a pairing between the two categories. The same | |
| reference video may therefore correspond to <code><Video 1></code> and | |
| <code><Audio 2></code>; different indices do not prevent them from coming | |
| from the same source asset.</p> | |
| <p>An ordinary reference video does not create <code><Audio N></code> merely | |
| because the file contains sound.</p> | |
| <p>An <code><Audio N></code> definition primarily states the audio's role and | |
| does not have to name the <code><Video N></code> it comes from. State the | |
| shared source only when needed to remove provenance ambiguity, for example:</p> | |
| <pre class="code"><Video 1> is the source video for the target video edit. | |
| <Audio 2> is the synchronized audio track of <Video 1> and is reused in the target video.</pre> | |
| `; | |
| const retention_analysis = ` | |
| <p>This section describes how each piece of referenced content is preserved, | |
| transferred, copied, or referenced in the target video. Use one line for each | |
| reference label and preserve the meaning established in | |
| <code>subject_definitions</code>.</p> | |
| <h4>4.1 Visible content</h4> | |
| <p><code><Subject N></code>, <code><Picture N></code>, and | |
| <code><Video N></code> use the following relationship markers. These markers | |
| are fixed English values in the output format:</p> | |
| <table> | |
| <thead><tr><th>Relationship marker</th><th>Meaning</th></tr></thead> | |
| <tbody> | |
| <tr><td><code>fully_preserved</code></td><td>The defined role of the referenced content is fully preserved</td></tr> | |
| <tr><td><code>partially_preserved</code></td><td>The referenced content is still used, but some defined characteristics are changed or only partially retained</td></tr> | |
| <tr><td><code>attribute_transfer</code></td><td>Referenced characteristics are transferred to a different identifiable target subject</td></tr> | |
| <tr><td><code>weak_reference</code></td><td>Only broad similarity in style, category, composition, or atmosphere is retained</td></tr> | |
| </tbody> | |
| </table> | |
| <p>Subject entry:</p> | |
| <pre class="code"><Subject 1> (appears in [Shot 1], [Shot 3]): fully_preserved - ...</pre> | |
| <p>Picture entry:</p> | |
| <pre class="code"><Picture 2> ([Shot 1] first frame): fully_preserved - ...</pre> | |
| <p>Video-structure entry:</p> | |
| <pre class="code"><Video 1> (cut and pacing structure): weak_reference - ...</pre> | |
| <h4>4.2 Audio</h4> | |
| <p><code><Audio N></code> uses the following relationship markers:</p> | |
| <table> | |
| <thead><tr><th>Relationship marker</th><th>Meaning</th></tr></thead> | |
| <tbody> | |
| <tr><td><code>fully_copy</code></td><td>The complete source audio serves as the target video's complete final audio track</td></tr> | |
| <tr><td><code>partially_copy</code></td><td>Only part of the timeline or selected audio layers are copied, or other sounds are added, removed, or replaced after copying</td></tr> | |
| <tr><td><code>reference</code></td><td>The signal is not copied directly; only timbre, rhythm, music style, dialogue content, or sound texture is referenced</td></tr> | |
| <tr><td><code>weak_reference</code></td><td>Only broad similarity in category or atmosphere is retained</td></tr> | |
| </tbody> | |
| </table> | |
| <pre class="code"><Audio 1>: fully_copy - <Audio 1> is reused 1:1 as the target video's complete final audio track.</pre> | |
| <pre class="code"><Audio 2>: reference - the target speaker follows <Audio 2>'s voice timbre and measured delivery without copying the original signal.</pre> | |
| <p>Choose each relationship marker only within the reference role already defined | |
| for that label in <code>subject_definitions</code>. Do not treat newly added | |
| actions, backgrounds, or plot events in the target video as losses of reference | |
| fidelity.</p> | |
| `; | |
| /** @type {Record<string, string>} section name -> HTML help fragment */ | |
| const SECTION_HELP = { | |
| subject_definitions, | |
| summary, | |
| retention_analysis, | |
| detailed_description, | |
| overall_soundscape, | |
| non_diegetic_music, | |
| }; | |
| exports.SECTION_HELP = SECTION_HELP; | |
| }); | |
| register("js/highlight.js", function (module, exports, require) { | |
| /** | |
| * Token highlighting for the prompt fields. Because a <textarea> cannot render | |
| * styled spans, we mirror its text into a backdrop <div> where key tokens are | |
| * wrapped in <mark>. Three token classes: | |
| * | |
| * hl-shot [Shot 1], [Shot 2] ... (a bracket starting with "Shot") | |
| * hl-bracket other [ ... ] tokens (task-type prefix, [English], [unclear]) | |
| * hl-angle < ... > tokens (<Subject 1>, <Audio 1>, <d>, </d> ...) | |
| */ | |
| function escapeHtml(s) { | |
| return String(s).replace( | |
| /[&<>]/g, | |
| (c) => ({ "&": "&", "<": "<", ">": ">" }[c]) | |
| ); | |
| } | |
| function highlightHtml(raw) { | |
| const re = /(<[^>\n]*>)|(\[[^\]\n]*\])/g; | |
| let out = ""; | |
| let last = 0; | |
| let m; | |
| while ((m = re.exec(raw)) !== null) { | |
| out += escapeHtml(raw.slice(last, m.index)); | |
| const tok = m[0]; | |
| let cls; | |
| if (m[1]) cls = "hl-angle"; | |
| else cls = /^\[\s*Shot\b/i.test(tok) ? "hl-shot" : "hl-bracket"; | |
| out += `<mark class="${cls}">${escapeHtml(tok)}</mark>`; | |
| last = re.lastIndex; | |
| } | |
| out += escapeHtml(raw.slice(last)); | |
| return out; | |
| } | |
| exports.escapeHtml = escapeHtml; | |
| exports.highlightHtml = highlightHtml; | |
| }); | |
| register("js/task-types.js", function (module, exports, require) { | |
| /** | |
| * Task types for the summary section's square-bracketed prefix | |
| * (e.g. `[video editing + reference generation + audio reuse]`). | |
| * | |
| * The checkbox UI drives this prefix; the prefix lives inside the summary text, | |
| * so nothing extra is persisted — on reload the checkboxes are re-derived from | |
| * the leading `[...]` of the summary. | |
| */ | |
| const TASK_TYPES = [ | |
| "keyframe completion", | |
| "reference generation", | |
| "video editing", | |
| "video continuation", | |
| "audio reuse", | |
| "audio reference", | |
| ]; | |
| const TASK_TYPE_HINTS = { | |
| "keyframe completion": | |
| "An image serves as the target video's first frame, keyframe, last frame, edited keyframe, or another concrete frame anchor", | |
| "reference generation": | |
| "An image, video, or audio asset provides generation guidance for a character, scene, style, action, camera movement, storyboard, and so on, without serving as a concrete frame or as the source video being edited or continued", | |
| "video editing": | |
| "An existing source video is directly modified; editing an image or generating between still keyframes does not belong to this type", | |
| "video continuation": | |
| "New content continues, extends, resumes, or transitions from an existing source video", | |
| "audio reuse": "The same audio signal is reused in full or in part", | |
| "audio reference": | |
| "The audio signal is not copied directly; only its music style, timbre, dialogue or lyric content, sound-effect texture, beat, or continuity is referenced", | |
| }; | |
| /** | |
| * Split a summary into its leading `[...]` task-type prefix and the rest. | |
| * @param {string} text | |
| * @returns {{ types: string[], rest: string }} | |
| */ | |
| function parsePrefix(text) { | |
| const m = (text ?? "").match(/^\s*\[([^\]]*)\]\s*/); | |
| if (!m) return { types: [], rest: text ?? "" }; | |
| const types = m[1] | |
| .split("+") | |
| .map((s) => s.trim()) | |
| .filter(Boolean); | |
| return { types, rest: (text ?? "").slice(m[0].length) }; | |
| } | |
| /** | |
| * Rewrite the leading task-type prefix of a summary. An empty selection removes | |
| * the prefix entirely. | |
| * @param {string} text | |
| * @param {string[]} chosen task types, already in the desired order | |
| * @returns {string} | |
| */ | |
| function setPrefix(text, chosen) { | |
| const { rest } = parsePrefix(text); | |
| if (!chosen.length) return rest; | |
| return `[${chosen.join(" + ")}] ${rest}`; | |
| } | |
| exports.TASK_TYPES = TASK_TYPES; | |
| exports.TASK_TYPE_HINTS = TASK_TYPE_HINTS; | |
| exports.parsePrefix = parsePrefix; | |
| exports.setPrefix = setPrefix; | |
| }); | |
| register("js/llm/llm-config.js", function (module, exports, require) { | |
| const { idbGet, idbSet } = require("js/storage/idb.js"); | |
| /** | |
| * Global, app-wide LLM configuration. | |
| * | |
| * This is intentionally NOT tied to any one section: image prompts, the prompt | |
| * library, and future features all read the same settings to talk to a local | |
| * (or remote) OpenAI-compatible endpoint (LM Studio, Ollama, llama.cpp, ...). | |
| * | |
| * Persisted in IndexedDB as CONFIG only (base URL, model, key, temperature) — | |
| * never prompt or image content, so disk stays the single source of truth for | |
| * content. NOTE: an API key, if set, is stored in plaintext in IndexedDB; for | |
| * local servers no key is needed, so leave it empty. | |
| */ | |
| const IDB_KEY = "llmConfig"; | |
| /** Sensible default for LM Studio's local server. */ | |
| const DEFAULT_LLM_CONFIG = { | |
| baseUrl: "http://localhost:1234/v1", | |
| model: "", | |
| apiKey: "", | |
| temperature: 0.7, | |
| // Send `cache_prompt: false` so the server rebuilds context from our messages | |
| // each call instead of reusing the previous request's KV cache. Prevents a | |
| // prior image from "leaking" into later text generations in LM Studio. | |
| independentContext: true, | |
| }; | |
| /** | |
| * Live, app-wide config. Read this object anywhere; change it only through | |
| * `saveLlmConfig` so the persisted copy stays in sync. | |
| */ | |
| const llmConfig = { ...DEFAULT_LLM_CONFIG }; | |
| /** Trim and drop any trailing slash so paths can be appended cleanly. */ | |
| function normalizeBaseUrl(url) { | |
| return String(url || "").trim().replace(/\/+$/, ""); | |
| } | |
| /** Load persisted config into `llmConfig`. Call once at startup. */ | |
| async function loadLlmConfig() { | |
| const saved = await idbGet(IDB_KEY).catch(() => null); | |
| if (saved && typeof saved === "object") Object.assign(llmConfig, saved); | |
| llmConfig.baseUrl = normalizeBaseUrl(llmConfig.baseUrl); | |
| return llmConfig; | |
| } | |
| /** Merge a patch into the config and persist it. */ | |
| async function saveLlmConfig(patch) { | |
| Object.assign(llmConfig, patch); | |
| llmConfig.baseUrl = normalizeBaseUrl(llmConfig.baseUrl); | |
| await idbSet(IDB_KEY, { ...llmConfig }); | |
| return llmConfig; | |
| } | |
| /** True when there is enough config to attempt a call (base URL + model). */ | |
| function isLlmConfigured() { | |
| return !!(llmConfig.baseUrl && llmConfig.model); | |
| } | |
| /** | |
| * Live connectivity status, updated by the last probe (see refreshLlmStatus). | |
| * "off" = not configured, "checking" = probing, "warn" = configured but | |
| * unreachable, "on" = connected. Features enable AI actions off this. | |
| */ | |
| const llmStatus = { state: "off" }; | |
| const statusListeners = new Set(); | |
| /** Subscribe to status changes; returns an unsubscribe function. */ | |
| function onLlmStatusChange(cb) { | |
| statusListeners.add(cb); | |
| return () => statusListeners.delete(cb); | |
| } | |
| /** Set the current status and notify subscribers. */ | |
| function setLlmStatus(state) { | |
| llmStatus.state = state; | |
| for (const cb of statusListeners) { | |
| try { | |
| cb(state); | |
| } catch { | |
| /* a listener error must not break the others */ | |
| } | |
| } | |
| } | |
| /** True when the last probe found the LLM reachable. */ | |
| function isLlmOnline() { | |
| return llmStatus.state === "on"; | |
| } | |
| exports.DEFAULT_LLM_CONFIG = DEFAULT_LLM_CONFIG; | |
| exports.llmConfig = llmConfig; | |
| exports.loadLlmConfig = loadLlmConfig; | |
| exports.saveLlmConfig = saveLlmConfig; | |
| exports.isLlmConfigured = isLlmConfigured; | |
| exports.llmStatus = llmStatus; | |
| exports.onLlmStatusChange = onLlmStatusChange; | |
| exports.setLlmStatus = setLlmStatus; | |
| exports.isLlmOnline = isLlmOnline; | |
| }); | |
| register("js/text-ai.js", function (module, exports, require) { | |
| const { chat } = require("js/llm/llm-client.js"); | |
| const { isLlmOnline } = require("js/llm/llm-config.js"); | |
| const { listTemplates, getSelectedTemplateId, setSelectedTemplate, getTemplateValue, getTemplateModel } = require("js/llm/llm-prompts.js"); | |
| const { toast } = require("js/toast.js"); | |
| /** | |
| * Generic text-generation modal used by the Prompt Library Generate features | |
| * (MiniMax scene, Default plain-text prompt, and future ones). | |
| * | |
| * A caller opens it for a template PURPOSE with an optional context string; the | |
| * modal sends system + user (+ context) to the LLM and hands the RAW result back | |
| * via `onApply`, so each caller applies it its own way (parse into a scene, set a | |
| * text field, …). Purpose-specific input UIs (e.g. loading images) live in the | |
| * caller, not here. | |
| */ | |
| const $ = (id) => document.getElementById(id); | |
| const GEN_TIMEOUT_MS = 120000; | |
| let el; | |
| /** @type {{purpose:string,onApply:(t:string)=>void,context?:string,contextLabel?:string,validate?:(t:string)=>boolean}|null} */ | |
| let cfg = null; | |
| let inflight = null; | |
| let baseHint = "(editable)"; | |
| /** Conversation so far (null before the first generation). Refine appends turns. */ | |
| let history = null; | |
| function initTextAi() { | |
| el = { | |
| root: $("textGenModal"), | |
| title: $("textGenTitle"), | |
| closeBtn: $("textGenCloseBtn"), | |
| template: $("textGenTemplate"), | |
| system: $("textGenSystem"), | |
| user: $("textGenUser"), | |
| contWrap: $("textGenContWrap"), | |
| contCheck: $("textGenCont"), | |
| contLabel: $("textGenContLabel"), | |
| refWrap: $("textGenRefWrap"), | |
| refLabel: $("textGenRefLabel"), | |
| ref: $("textGenRef"), | |
| ctxNote: $("textGenCtxNote"), | |
| runBtn: $("textGenRunBtn"), | |
| refineBtn: $("textGenRefineBtn"), | |
| stopBtn: $("textGenStopBtn"), | |
| status: $("textGenStatus"), | |
| resultHint: $("textGenResultHint"), | |
| result: $("textGenResult"), | |
| acceptBtn: $("textGenAcceptBtn"), | |
| }; | |
| el.closeBtn.addEventListener("click", close); | |
| el.root.querySelector(".modal-backdrop").addEventListener("click", close); | |
| document.addEventListener("keydown", (ev) => { | |
| if (!el.root.hidden && ev.key === "Escape") { | |
| ev.preventDefault(); | |
| close(); | |
| } | |
| }); | |
| el.runBtn.addEventListener("click", run); | |
| el.refineBtn.addEventListener("click", refine); | |
| el.stopBtn.addEventListener("click", abortInflight); | |
| el.acceptBtn.addEventListener("click", accept); | |
| el.template.addEventListener("change", onTemplateChange); | |
| el.result.addEventListener("input", () => syncButtons()); | |
| } | |
| /** | |
| * `continuation` (optional) shows a checkbox; when ticked, its context is added | |
| * to the request so the model can build on prior content (e.g. a previous scene). | |
| * | |
| * `validate(text)` (optional): when it returns false, the accept button is | |
| * disabled and the result hint warns — so an unparseable result must be fixed. | |
| * | |
| * @param {{ purpose:string, title:string, context?:string, contextLabel?:string, | |
| * resultHint?:string, acceptLabel?:string, onApply:(text:string)=>void, | |
| * continuation?:{ label:string, contextLabel:string, context:string }, | |
| * validate?:(text:string)=>boolean }} opts | |
| */ | |
| function openTextAi(opts) { | |
| if (!isLlmOnline()) { | |
| toast("AI not connected — check AI settings.", "warn"); | |
| return; | |
| } | |
| cfg = opts; | |
| el.title.textContent = opts.title || "Generate with AI"; | |
| baseHint = opts.resultHint || "(editable)"; | |
| el.resultHint.textContent = baseHint; | |
| el.acceptBtn.textContent = opts.acceptLabel || "Use"; | |
| // Optional "continue from …" checkbox (on by default when offered). | |
| if (opts.continuation) { | |
| el.contLabel.textContent = opts.continuation.label; | |
| el.contCheck.checked = true; | |
| el.contWrap.hidden = false; | |
| } else { | |
| el.contWrap.hidden = true; | |
| } | |
| // Optional read-only reference (e.g. the subject_definitions being applied). | |
| if (opts.reference && (opts.reference.text || "").trim()) { | |
| el.refLabel.textContent = opts.reference.label || "Reference"; | |
| el.ref.value = opts.reference.text; | |
| el.refWrap.hidden = false; | |
| } else { | |
| el.refWrap.hidden = true; | |
| el.ref.value = ""; | |
| } | |
| populateTemplateSelect(); | |
| const sel = getSelectedTemplateId(opts.purpose); | |
| el.template.value = sel; | |
| applyTemplate(sel); | |
| el.result.value = ""; | |
| history = null; // fresh session — no conversation yet | |
| el.runBtn.textContent = "Generate"; | |
| el.refineBtn.hidden = true; // appears after the first generation | |
| updateCtxNote(); | |
| setBusy(false); | |
| setStatus("Ready.", ""); | |
| el.root.hidden = false; | |
| } | |
| function close() { | |
| abortInflight(); | |
| el.root.hidden = true; | |
| } | |
| function populateTemplateSelect() { | |
| el.template.textContent = ""; | |
| for (const t of listTemplates(cfg.purpose)) { | |
| const o = document.createElement("option"); | |
| o.value = t.id; | |
| o.textContent = t.name; | |
| el.template.appendChild(o); | |
| } | |
| } | |
| function applyTemplate(id) { | |
| el.system.value = getTemplateValue(cfg.purpose, id, "system"); | |
| el.user.value = getTemplateValue(cfg.purpose, id, "user"); | |
| } | |
| function onTemplateChange() { | |
| applyTemplate(el.template.value); | |
| setSelectedTemplate(cfg.purpose, el.template.value); | |
| } | |
| function updateCtxNote() { | |
| el.ctxNote.textContent = | |
| cfg.context && cfg.context.trim() | |
| ? "The current content is sent as context to improve/complete." | |
| : "Generating from your instructions."; | |
| } | |
| /** Build the first-turn messages from the fields + context (fresh generation). */ | |
| function buildFreshMessages() { | |
| // Reference context first, then the user's brief LAST, so the model treats | |
| // the brief as the task to perform rather than as more context to echo. | |
| const parts = []; | |
| if (cfg.reference && (cfg.reference.text || "").trim()) { | |
| parts.push(`${cfg.reference.label}:\n${cfg.reference.text.trim()}`); | |
| } | |
| const ctx = (cfg.context || "").trim(); | |
| // A pre-structured context (with its own internal labels) passes an empty | |
| // contextLabel and is included as-is; otherwise it gets a wrapping label. | |
| if (ctx) parts.push(cfg.contextLabel ? `${cfg.contextLabel}:\n${ctx}` : ctx); | |
| if (cfg.continuation && el.contCheck.checked && cfg.continuation.context.trim()) { | |
| parts.push(`${cfg.continuation.contextLabel}:\n${cfg.continuation.context.trim()}`); | |
| } | |
| if (el.user.value.trim()) parts.push(`TASK (do this now):\n${el.user.value.trim()}`); | |
| const userText = parts.join("\n\n") || el.user.value; | |
| const messages = []; | |
| if (el.system.value.trim()) messages.push({ role: "system", content: el.system.value }); | |
| messages.push({ role: "user", content: userText }); | |
| return messages; | |
| } | |
| /** Send a message list; sets the result and returns the assistant text (null on error). */ | |
| async function send(messages) { | |
| abortInflight(); | |
| const ctrl = new AbortController(); | |
| inflight = ctrl; | |
| const timer = setTimeout(() => ctrl.abort(), GEN_TIMEOUT_MS); | |
| setBusy(true); | |
| setStatus("Generating…", ""); | |
| try { | |
| const text = await chat(messages, { | |
| signal: ctrl.signal, | |
| model: getTemplateModel(cfg.purpose, el.template.value), | |
| }); | |
| el.result.value = text.trim(); | |
| setStatus( | |
| el.result.value ? "Done." : "The model returned an empty response.", | |
| el.result.value ? "ok" : "warn" | |
| ); | |
| return el.result.value; | |
| } catch (err) { | |
| setStatus(err.message, "error"); | |
| return null; | |
| } finally { | |
| clearTimeout(timer); | |
| inflight = null; | |
| setBusy(false); | |
| } | |
| } | |
| /** Once a result exists, reveal Refine and relabel the primary button. */ | |
| function afterGenerated() { | |
| el.runBtn.textContent = "Regenerate"; | |
| el.refineBtn.hidden = false; | |
| } | |
| /** Fresh generation: rebuild from the fields, discarding any prior conversation. */ | |
| async function run() { | |
| const messages = buildFreshMessages(); | |
| const text = await send(messages); | |
| if (text != null) { | |
| history = [...messages, { role: "assistant", content: el.result.value }]; | |
| afterGenerated(); | |
| } | |
| } | |
| /** Refine: keep the conversation and send the current user prompt as a new turn. */ | |
| async function refine() { | |
| if (!history) return run(); | |
| // Use the (possibly hand-edited) result as the last assistant turn. | |
| const base = history.slice(); | |
| if (base.length && base[base.length - 1].role === "assistant") { | |
| base[base.length - 1] = { role: "assistant", content: el.result.value }; | |
| } | |
| const instruction = el.user.value.trim() || "Refine the previous result."; | |
| const messages = [...base, { role: "user", content: instruction }]; | |
| const text = await send(messages); | |
| if (text != null) { | |
| history = [...messages, { role: "assistant", content: el.result.value }]; | |
| afterGenerated(); | |
| } | |
| } | |
| function accept() { | |
| const text = el.result.value; | |
| if (!text.trim()) return; | |
| cfg.onApply?.(text); | |
| toast("Applied — review and save."); | |
| close(); | |
| } | |
| function abortInflight() { | |
| if (inflight) { | |
| inflight.abort(); | |
| inflight = null; | |
| } | |
| } | |
| function setBusy(busy) { | |
| el.stopBtn.hidden = !busy; | |
| el.refineBtn.disabled = busy; | |
| syncButtons(busy); // handles runBtn + acceptBtn | |
| } | |
| function syncButtons(busy = false) { | |
| el.runBtn.disabled = busy; | |
| const text = el.result.value; | |
| const hasResult = text.trim().length > 0; | |
| const valid = !cfg?.validate || (hasResult && cfg.validate(text)); | |
| el.acceptBtn.disabled = busy || !hasResult || !valid; | |
| // Warn in the result hint when there is content that can't be parsed. | |
| el.resultHint.textContent = | |
| cfg?.validate && hasResult && !valid | |
| ? `${baseHint} — ⚠ can't be parsed; fix it to enable "${cfg.acceptLabel || "Use"}"` | |
| : baseHint; | |
| } | |
| function setStatus(text, kind) { | |
| el.status.textContent = text; | |
| el.status.className = "llm-status" + (kind ? " " + kind : ""); | |
| } | |
| exports.initTextAi = initTextAi; | |
| exports.openTextAi = openTextAi; | |
| }); | |
| register("js/llm/llm-client.js", function (module, exports, require) { | |
| const { llmConfig } = require("js/llm/llm-config.js"); | |
| /** | |
| * The ONLY module that performs LLM network calls (mirrors the storage-adapter | |
| * rule: no feature fetches a model endpoint directly). It speaks the OpenAI- | |
| * compatible REST protocol, so the same code works with LM Studio, Ollama | |
| * (`:11434/v1`), llama.cpp, Jan, vLLM, and cloud OpenAI — only the base URL and | |
| * model change, both read from the global `llmConfig`. | |
| * | |
| * Every feature (generate / complete / improve prompts, ...) should build its | |
| * messages and call `chat()` here. | |
| */ | |
| function authHeaders() { | |
| const h = { "Content-Type": "application/json" }; | |
| if (llmConfig.apiKey) h.Authorization = `Bearer ${llmConfig.apiKey}`; | |
| return h; | |
| } | |
| function endpoint(path) { | |
| const base = String(llmConfig.baseUrl || "").replace(/\/+$/, ""); | |
| if (!base) throw new Error("No LLM base URL configured — open AI settings."); | |
| return base + path; | |
| } | |
| /** A cross-origin (CORS) block and a down server both surface as a TypeError. */ | |
| function networkError(err) { | |
| // An aborted fetch (our timeout) throws a DOMException named "AbortError". | |
| if (err && err.name === "AbortError") { | |
| return new Error( | |
| `Timed out reaching the LLM at "${llmConfig.baseUrl}" — is the server ` + | |
| `running and the URL correct?` | |
| ); | |
| } | |
| return new Error( | |
| `Could not reach the LLM at "${llmConfig.baseUrl}". Is the server running ` + | |
| `and is CORS enabled? (${err.message})` | |
| ); | |
| } | |
| /** | |
| * Send a chat completion and return the assistant's text. | |
| * | |
| * `content` may be a plain string, or — for vision models — an array of OpenAI | |
| * content parts, e.g. `[{type:"text",text}, {type:"image_url",image_url:{url}}]` | |
| * where `url` can be a `data:` URL (base64), which is how we send images. | |
| * | |
| * @param {{role:"system"|"user"|"assistant", content:string|object[]}[]} messages | |
| * @param {{ temperature?: number, model?: string, signal?: AbortSignal, | |
| * responseFormat?: object }} [opts] | |
| * @returns {Promise<string>} | |
| */ | |
| async function chat(messages, opts = {}) { | |
| const model = opts.model || llmConfig.model; | |
| if (!model) throw new Error("No model selected — open AI settings."); | |
| const body = { | |
| model, | |
| messages, | |
| temperature: opts.temperature ?? llmConfig.temperature ?? 0.7, | |
| stream: false, | |
| }; | |
| // llama.cpp/LM Studio extension: don't reuse the previous request's cached | |
| // context (avoids a prior image bleeding into a fresh generation). Unknown to | |
| // servers that don't support it, which simply ignore it. | |
| if (llmConfig.independentContext) body.cache_prompt = false; | |
| if (opts.responseFormat) body.response_format = opts.responseFormat; | |
| let res; | |
| try { | |
| res = await fetch(endpoint("/chat/completions"), { | |
| method: "POST", | |
| headers: authHeaders(), | |
| body: JSON.stringify(body), | |
| signal: opts.signal, | |
| }); | |
| } catch (err) { | |
| throw networkError(err); | |
| } | |
| if (!res.ok) { | |
| const detail = await res.text().catch(() => ""); | |
| throw new Error(`LLM error ${res.status}: ${detail.slice(0, 300)}`); | |
| } | |
| const data = await res.json(); | |
| return data?.choices?.[0]?.message?.content ?? ""; | |
| } | |
| /** | |
| * List model ids from the endpoint (GET /models) to populate the settings | |
| * picker. Throws on failure so the caller can show the reason. | |
| * @param {{ signal?: AbortSignal }} [opts] | |
| * @returns {Promise<string[]>} | |
| */ | |
| async function listModels(opts = {}) { | |
| let res; | |
| try { | |
| res = await fetch(endpoint("/models"), { | |
| headers: authHeaders(), | |
| signal: opts.signal, | |
| }); | |
| } catch (err) { | |
| throw networkError(err); | |
| } | |
| if (!res.ok) throw new Error(`LLM error ${res.status}`); | |
| const data = await res.json(); | |
| const list = Array.isArray(data?.data) ? data.data : []; | |
| return list | |
| .map((m) => m.id) | |
| .filter(Boolean) | |
| .sort((a, b) => a.localeCompare(b)); | |
| } | |
| /** | |
| * Probe the endpoint for the settings "Test connection" button and the status | |
| * indicator. | |
| * @param {{ signal?: AbortSignal }} [opts] | |
| * @returns {Promise<{ok: boolean, message: string, models: string[]}>} | |
| */ | |
| async function testConnection(opts = {}) { | |
| try { | |
| const models = await listModels(opts); | |
| return { | |
| ok: true, | |
| models, | |
| message: models.length | |
| ? `Connected — ${models.length} model(s) available.` | |
| : "Connected, but no models are loaded on the server.", | |
| }; | |
| } catch (err) { | |
| return { ok: false, models: [], message: err.message }; | |
| } | |
| } | |
| exports.chat = chat; | |
| exports.listModels = listModels; | |
| exports.testConnection = testConnection; | |
| }); | |
| register("js/llm/llm-prompts.js", function (module, exports, require) { | |
| const { idbGet, idbSet } = require("js/storage/idb.js"); | |
| const { DEFAULT_TYPE, MINIMAX_TYPE, promptTypeLabel } = require("js/prompt-types.js"); | |
| const { DEFAULT_PROMPTS } = require("js/llm/llm-default-prompts.js"); | |
| /** | |
| * Named prompt TEMPLATES used by AI features. | |
| * | |
| * Model: | |
| * - A "purpose" (e.g. `image.generate`) defines the fields a template holds | |
| * (system + user) and a built-in, read-only "Default". | |
| * - Each purpose has zero or more USER templates (named bundles of those field | |
| * values), plus the built-in Default. At use time a feature picks one. | |
| * - The last-picked template id per purpose is remembered (`selected`). | |
| * | |
| * Purposes are placed in a hierarchy so the manager sidebar mirrors the app: | |
| * | |
| * area ("Image prompts" | "Prompt Library") | |
| * └─ promptType (Prompt Library only: the library prompt type, e.g. H3) | |
| * └─ purpose ("Scene", "Subject definitions", "Section refiner", …) | |
| * | |
| * Add a purpose here and it shows up automatically under its area/type. Different | |
| * purposes can back very different Generate UIs (a scene generator vs. one that | |
| * loads images from disk); that behavior lives in the feature module — this file | |
| * only stores the prompt templates. | |
| * | |
| * Persisted in IndexedDB as config only (never content). | |
| * | |
| * A template may also pin its own `model` (empty = use the global model from AI | |
| * settings). LM Studio loads it on demand when a request names a model that | |
| * isn't loaded (needs Just-In-Time model loading enabled), so switching models | |
| * per template can slow that first call. | |
| * | |
| * @typedef {{key:string,label:string,description:string}} FieldDef | |
| * @typedef {{id:string,area:string,promptType:(string|null),label:string,description:string,fields:FieldDef[],default:Record<string,string>}} PurposeDef | |
| * @typedef {{id:string,name:string,values:Record<string,string>,model?:string}} UserTemplate | |
| */ | |
| const IDB_KEY = "llmTemplates"; | |
| const LEGACY_KEY = "llmPrompts"; // single-value model, migrated on first load | |
| /** Reserved id of the built-in, read-only template. */ | |
| const DEFAULT_TEMPLATE_ID = "default"; | |
| /** Built-in default prompts for a purpose (from the generated map). */ | |
| const def = (id) => DEFAULT_PROMPTS[id] || {}; | |
| /** @type {PurposeDef[]} */ | |
| const PURPOSE_DEFS = [ | |
| { | |
| id: "image.generate", | |
| area: "Image prompts", | |
| promptType: null, | |
| label: "Generate", | |
| description: | |
| "Used by the ✨ Generate button on an image to caption it with a vision model.", | |
| fields: [ | |
| { | |
| key: "system", | |
| label: "System prompt", | |
| description: "The model's role/instructions.", | |
| }, | |
| { | |
| key: "user", | |
| label: "User prompt", | |
| description: "The request sent alongside the image.", | |
| }, | |
| ], | |
| default: def("image.generate"), | |
| }, | |
| { | |
| id: "scene.generate", | |
| area: "Prompt Library", | |
| promptType: MINIMAX_TYPE, | |
| label: "Scene", | |
| description: | |
| "Used by the ✨ Generate button on a MiniMax H3 Ref2VA scene.", | |
| fields: [ | |
| { | |
| key: "system", | |
| label: "System prompt", | |
| description: "Teaches the model the canonical scene/section format.", | |
| }, | |
| { | |
| key: "user", | |
| label: "User prompt", | |
| description: "Your brief. The current scene is sent as context to refine.", | |
| }, | |
| ], | |
| default: def("scene.generate"), | |
| }, | |
| { | |
| id: "library.h3.subject", | |
| area: "Prompt Library", | |
| promptType: MINIMAX_TYPE, | |
| label: "Subject definitions", | |
| description: | |
| "Used by the globals ✨ Generate: describes a reference image as one " + | |
| "subject_definitions line for a <Subject N> or <Picture N> label.", | |
| fields: [ | |
| { | |
| key: "system", | |
| label: "System prompt", | |
| description: "Teaches the single-line subject_definitions format.", | |
| }, | |
| { | |
| key: "user", | |
| label: "User prompt", | |
| description: "Base instruction; the target label is appended per image.", | |
| }, | |
| ], | |
| default: def("library.h3.subject"), | |
| }, | |
| { | |
| id: "library.h3.review", | |
| area: "Prompt Library", | |
| promptType: MINIMAX_TYPE, | |
| label: "Review", | |
| description: | |
| "Used by the 🔍 Review button: reviews the WHOLE prompt (globals + all " + | |
| "scenes) for cross-scene consistency and improvements. Advisory only.", | |
| fields: [ | |
| { | |
| key: "system", | |
| label: "System prompt", | |
| description: "The reviewer's role and the H3 review checklist.", | |
| }, | |
| { | |
| key: "user", | |
| label: "User prompt", | |
| description: "Your review brief. The whole prompt is sent as context.", | |
| }, | |
| ], | |
| default: def("library.h3.review"), | |
| }, | |
| { | |
| id: "library.h3.scene-review", | |
| area: "Prompt Library", | |
| promptType: MINIMAX_TYPE, | |
| label: "Scene review", | |
| description: | |
| "Used by the 🔍 Review button on a scene: reviews ONE scene against its " + | |
| "neighbours + globals; 'Apply suggestions' can rewrite the scene.", | |
| fields: [ | |
| { | |
| key: "system", | |
| label: "System prompt", | |
| description: "The reviewer's role and the single-scene review checklist.", | |
| }, | |
| { | |
| key: "user", | |
| label: "User prompt", | |
| description: "Your review brief. The scene + its neighbours are sent as context.", | |
| }, | |
| ], | |
| default: def("library.h3.scene-review"), | |
| }, | |
| { | |
| id: "library.default.generate", | |
| area: "Prompt Library", | |
| promptType: DEFAULT_TYPE, | |
| label: "Generate", | |
| description: | |
| "Used by the ✨ Generate button on a Default (plain-text) prompt.", | |
| fields: [ | |
| { | |
| key: "system", | |
| label: "System prompt", | |
| description: "The model's role/instructions.", | |
| }, | |
| { | |
| key: "user", | |
| label: "User prompt", | |
| description: "Your brief. The current prompt is sent as context to refine.", | |
| }, | |
| ], | |
| default: def("library.default.generate"), | |
| }, | |
| ]; | |
| function getPurpose(id) { | |
| return PURPOSE_DEFS.find((p) => p.id === id) || null; | |
| } | |
| /** | |
| * The purpose hierarchy for the manager sidebar, in first-seen order: | |
| * [{ area, groups: [{ type, label, purposes: PurposeDef[] }] }] | |
| * A group with `type: null` (label null) is a flat list directly under the area; | |
| * otherwise `label` is the prompt-type heading (e.g. "MiniMax H3 Ref2VA"). | |
| */ | |
| function promptTree() { | |
| const areas = []; | |
| for (const p of PURPOSE_DEFS) if (!areas.includes(p.area)) areas.push(p.area); | |
| return areas.map((area) => { | |
| const groups = []; | |
| for (const p of PURPOSE_DEFS.filter((x) => x.area === area)) { | |
| const type = p.promptType || null; | |
| let g = groups.find((x) => x.type === type); | |
| if (!g) { | |
| g = { type, label: type ? promptTypeLabel(type) : null, purposes: [] }; | |
| groups.push(g); | |
| } | |
| g.purposes.push(p); | |
| } | |
| return { area, groups }; | |
| }); | |
| } | |
| /** | |
| * Live, app-wide store: `{ [purposeId]: { selected: string, templates: UserTemplate[] } }`. | |
| * The built-in Default is NOT stored here; it always comes from PURPOSE_DEFS. | |
| */ | |
| const llmTemplates = {}; | |
| const clone = (o) => JSON.parse(JSON.stringify(o)); | |
| function persist() { | |
| return idbSet(IDB_KEY, clone(llmTemplates)); | |
| } | |
| /** Ensure every known purpose has a well-formed entry. */ | |
| function normalize() { | |
| for (const p of PURPOSE_DEFS) { | |
| const s = llmTemplates[p.id]; | |
| if (!s || typeof s !== "object") { | |
| llmTemplates[p.id] = { selected: DEFAULT_TEMPLATE_ID, templates: [], defaultModel: "" }; | |
| continue; | |
| } | |
| if (!Array.isArray(s.templates)) s.templates = []; | |
| if (!s.selected) s.selected = DEFAULT_TEMPLATE_ID; | |
| if (typeof s.defaultModel !== "string") s.defaultModel = ""; // model for built-in Default | |
| } | |
| } | |
| /** Carry a customized single-value config (old model) into a "Custom" template. */ | |
| async function migrateFromLegacy() { | |
| const old = await idbGet(LEGACY_KEY).catch(() => null); | |
| if (!old || typeof old !== "object") return; | |
| const p = getPurpose("image.generate"); | |
| const sys = old["image.generate.system"]; | |
| const usr = old["image.generate.user"]; | |
| const changed = | |
| (sys != null && sys !== p.default.system) || | |
| (usr != null && usr !== p.default.user); | |
| if (changed) { | |
| llmTemplates["image.generate"] = { | |
| selected: "t_migrated", | |
| templates: [ | |
| { | |
| id: "t_migrated", | |
| name: "Custom", | |
| values: { system: sys ?? p.default.system, user: usr ?? p.default.user }, | |
| }, | |
| ], | |
| }; | |
| } | |
| } | |
| /** Load persisted templates. Call once at startup. */ | |
| async function loadLlmTemplates() { | |
| const saved = await idbGet(IDB_KEY).catch(() => null); | |
| if (saved && typeof saved === "object") Object.assign(llmTemplates, saved); | |
| else await migrateFromLegacy(); | |
| normalize(); | |
| return llmTemplates; | |
| } | |
| /** | |
| * Persist the full edited structure (from the templates manager). Validates each | |
| * `selected` still points at an existing template, else falls back to Default. | |
| * @param {Record<string, {selected:string, templates:UserTemplate[]}>} draft | |
| */ | |
| async function saveLlmTemplates(draft) { | |
| for (const p of PURPOSE_DEFS) { | |
| const d = (draft && draft[p.id]) || {}; | |
| const templates = Array.isArray(d.templates) | |
| ? d.templates.map((t) => cleanTemplate(p, t)) | |
| : []; | |
| let selected = d.selected || DEFAULT_TEMPLATE_ID; | |
| if ( | |
| selected !== DEFAULT_TEMPLATE_ID && | |
| !templates.some((t) => t.id === selected) | |
| ) { | |
| selected = DEFAULT_TEMPLATE_ID; | |
| } | |
| llmTemplates[p.id] = { selected, templates, defaultModel: (d.defaultModel || "").trim() }; | |
| } | |
| await persist(); | |
| return llmTemplates; | |
| } | |
| /** Keep only known fields; ensure id/name are present. */ | |
| function cleanTemplate(purpose, t) { | |
| const values = {}; | |
| for (const f of purpose.fields) values[f.key] = (t.values && t.values[f.key]) || ""; | |
| return { id: t.id, name: t.name || "Untitled", values, model: t.model || "" }; | |
| } | |
| /** Persist just the selected template id for a purpose (used at generation). */ | |
| async function setSelectedTemplate(purposeId, templateId) { | |
| const s = llmTemplates[purposeId] || (llmTemplates[purposeId] = { | |
| selected: DEFAULT_TEMPLATE_ID, | |
| templates: [], | |
| }); | |
| s.selected = templateId; | |
| await persist(); | |
| } | |
| /** All templates for a purpose: the built-in Default first, then user ones. */ | |
| function listTemplates(purposeId) { | |
| const p = getPurpose(purposeId); | |
| const builtin = { | |
| id: DEFAULT_TEMPLATE_ID, | |
| name: "Default", | |
| values: { ...(p ? p.default : {}) }, | |
| model: (llmTemplates[purposeId] && llmTemplates[purposeId].defaultModel) || "", | |
| builtin: true, | |
| }; | |
| const users = ((llmTemplates[purposeId] && llmTemplates[purposeId].templates) || []).map( | |
| (t) => ({ ...t, builtin: false }) | |
| ); | |
| return [builtin, ...users]; | |
| } | |
| /** A template by id (falls back to the built-in Default). */ | |
| function getTemplate(purposeId, templateId) { | |
| const all = listTemplates(purposeId); | |
| return all.find((t) => t.id === templateId) || all[0]; | |
| } | |
| /** The remembered selection id, validated against the current list. */ | |
| function getSelectedTemplateId(purposeId) { | |
| const sel = (llmTemplates[purposeId] && llmTemplates[purposeId].selected) || DEFAULT_TEMPLATE_ID; | |
| return listTemplates(purposeId).some((t) => t.id === sel) ? sel : DEFAULT_TEMPLATE_ID; | |
| } | |
| /** A single field value for a template (falls back to the built-in default). */ | |
| function getTemplateValue(purposeId, templateId, fieldKey) { | |
| const t = getTemplate(purposeId, templateId); | |
| const p = getPurpose(purposeId); | |
| return (t?.values?.[fieldKey] ?? p?.default?.[fieldKey] ?? "").toString(); | |
| } | |
| /** The model a template pins, or "" to use the global model (AI settings). */ | |
| function getTemplateModel(purposeId, templateId) { | |
| if (templateId === DEFAULT_TEMPLATE_ID) { | |
| return (llmTemplates[purposeId] && llmTemplates[purposeId].defaultModel) || ""; | |
| } | |
| const t = getTemplate(purposeId, templateId); | |
| return (t && t.model) || ""; | |
| } | |
| exports.DEFAULT_TEMPLATE_ID = DEFAULT_TEMPLATE_ID; | |
| exports.PURPOSE_DEFS = PURPOSE_DEFS; | |
| exports.getPurpose = getPurpose; | |
| exports.promptTree = promptTree; | |
| exports.llmTemplates = llmTemplates; | |
| exports.loadLlmTemplates = loadLlmTemplates; | |
| exports.saveLlmTemplates = saveLlmTemplates; | |
| exports.setSelectedTemplate = setSelectedTemplate; | |
| exports.listTemplates = listTemplates; | |
| exports.getTemplate = getTemplate; | |
| exports.getSelectedTemplateId = getSelectedTemplateId; | |
| exports.getTemplateValue = getTemplateValue; | |
| exports.getTemplateModel = getTemplateModel; | |
| }); | |
| register("js/llm/llm-default-prompts.js", function (module, exports, require) { | |
| // AUTO-GENERATED by scripts/gen-default-prompts.mjs — DO NOT EDIT BY HAND. | |
| // Edit the plain-text sources in prompts/ and re-run the generator: | |
| // node scripts/gen-default-prompts.mjs | |
| // (build.mjs also runs it before bundling, so dist/ stays fresh.) | |
| /** Built-in default AI prompts, keyed by purpose id then field. */ | |
| const DEFAULT_PROMPTS = { | |
| "image.generate": { | |
| "system": "You are an expert vision captioner for text-to-image and LoRA training. Look at the image and write ONE concise prompt describing it: main subject(s), appearance, clothing, action or pose, setting, composition, lighting, color palette, and art style. Use comma-separated phrases. Output only the prompt text — no preamble, no quotes, no explanations.", | |
| "user": "Write a detailed prompt for this image.", | |
| }, | |
| "library.default.generate": { | |
| "system": "You are an expert prompt writer. Based on the user's request, write a single, complete, ready-to-use prompt as plain text. Output only the prompt text — no preamble, no commentary, no markdown.", | |
| "user": "Write a detailed prompt.", | |
| }, | |
| "library.h3.review": { | |
| "system": "You are a senior reviewer for MiniMax H3 Ref2VA (full-reference) video prompts. You are given a whole prompt: the shared subject_definitions and, for each scene, its summary and detailed_description. REVIEW the prompt as a set — do NOT rewrite it.\n\nCheck, and report problems + concrete fixes, for:\n1. Subject/label consistency: every <Subject N> / <Picture N> used in a scene must be defined in subject_definitions; a label must keep the same identity across scenes; a scene must NOT re-describe a defined subject's fixed details (appearance, wardrobe, a setting's fixtures) — it should reference it by label. Flag invented, undefined, or redefined labels, and descriptions that contradict each other between scenes.\n2. Cross-scene continuity: subjects, setting, wardrobe and tone should stay continuous unless a change is clearly intended. Flag contradictions (a subject's clothing/appearance changing for no reason, day↔night jumps, a prop appearing or vanishing between scenes).\n3. No repetition: consecutive scenes must show NEW, different action — flag scenes that repeat, restate, or reuse a previous scene's wording, shots, or events.\n4. Format adherence per scene:\n - summary: one short paragraph using only existing <Subject N> / <Picture N> labels; it should carry a \"[task type]\" prefix.\n - detailed_description: TWO parts — (1) one short style+setting opening sentence with no [Shot] tag, then (2) a line starting with \"[Shot 1] …\". Default is a single shot with no timestamps unless multiple shots are clearly intended. Flag a missing opening sentence, a missing \"[Shot 1]\", or unjustified extra shots/timestamps.\n5. Completeness: flag empty / \"N/A\" / vague required content and note where a scene is underspecified for the model.\n\nReport format (plain text, concise, actionable):\n- \"Cross-scene issues\": consistency/continuity problems spanning scenes, each naming the scenes involved and a concrete fix.\n- \"Per-scene notes\": grouped under \"Scene N\", only scenes that have issues.\n- \"Suggestions\": a short, prioritized list of the highest-impact improvements.\nCite scenes as \"Scene N\". Do NOT rewrite the prompt or output full replacement scenes — give targeted, minimal fixes. If something is already solid, say so briefly.", | |
| "user": "Review this prompt for cross-scene consistency and suggest improvements.", | |
| }, | |
| "library.h3.scene-review": { | |
| "system": "You are a senior reviewer for MiniMax H3 Ref2VA (full-reference) video prompts. You are given the shared subject_definitions and three scenes: the PREVIOUS scene and the NEXT scene as read-only context, and the CURRENT scene, which is the ONLY one you review. REVIEW the CURRENT scene — do NOT rewrite it here.\n\nCheck, and report problems + concrete fixes, for the CURRENT scene:\n1. Subject/label consistency: every <Subject N> / <Picture N> it uses must be defined in subject_definitions and keep the same identity as in the neighbouring scenes; it must NOT re-describe a defined subject's fixed details (appearance, wardrobe, a setting's fixtures) — reference them by label. Flag invented, undefined, or redefined labels, and details that contradict the neighbours.\n2. Continuity with the neighbours: subjects, setting, wardrobe and tone should follow on from the PREVIOUS scene and set up the NEXT one, unless a change is clearly intended. Flag contradictions (clothing/appearance changing for no reason, day↔night jumps, a prop appearing or vanishing).\n3. No repetition: the CURRENT scene must show NEW, different action — flag any repetition, restatement, or reuse of the PREVIOUS scene's wording, shots, or events.\n4. Format: summary is one short paragraph using only existing labels with a \"[task type]\" prefix; detailed_description is TWO parts — one short style+setting opening sentence with no [Shot] tag, then a line starting with \"[Shot 1] …\" (single shot, no timestamps unless multiple shots are clearly intended). Flag a missing opening sentence, a missing \"[Shot 1]\", or unjustified extra shots/timestamps.\n5. Completeness: flag empty / \"N/A\" / vague required content and note where the scene is underspecified.\n\nReport concise, actionable notes for the CURRENT scene only (do NOT critique the neighbours), followed by a short \"Suggestions\" list of the highest-impact fixes. Do NOT output a rewritten scene here.", | |
| "user": "Review the current scene for consistency with its neighbours and suggest improvements.", | |
| }, | |
| "library.h3.subject": { | |
| "system": "You are an expert at writing MiniMax H3 Ref2VA `subject_definitions`. You are given a reference image and a target label such as <Subject 1> or <Picture 1>. Write EXACTLY ONE line defining that label.\n\n- For <Subject N>: describe the MAIN subject of the image.\n · If it is a person / people (or a specific animal / object / product): describe only that subject's identifying features (approximate age, gender, build, skin tone, hair, notable facial features and expression, clothing/accessories with colors); do NOT describe the surrounding background, environment, or lighting. If several people appear, describe each briefly.\n · If the image is a scene/environment with no salient person, animal, or object (e.g. an empty room, a public bathroom, a landscape): the environment IS the subject — describe its type, layout, key surfaces/fixtures/objects, materials, colors, and overall look.\n- For <Picture N>: describe the image as a concrete frame/composition anchor.\n\nBegin the line with the exact given label followed by ' is '. Output ONLY that one line — no preamble, no quotes, no extra lines.", | |
| "user": "Describe this reference image for the given label.", | |
| }, | |
| "scene.generate": { | |
| "system": "You write ONE scene for the MiniMax H3 Ref2VA (full-reference) video model. Output ONLY these sections, in order, each as \"section:\" on its own line with a blank line between them:\n\nsummary\ndetailed_description\noverall_soundscape\nnon_diegetic_music\n\nWrite everything in English except dialogue/lyrics inside <d> and text visible on screen.\n\nGENERAL\n- summary: one short paragraph using only the existing <Subject N> / <Picture N> labels; introduce no new labels.\n- If subject_definitions are provided, reuse those exact <Subject N> / <Picture N> labels; never invent new ones or redefine them.\n- Everything in subject_definitions (people, environments/settings, objects) is already defined there: reference it by its <Subject N> / <Picture N> label and do NOT re-describe or invent its fixed details (a person's clothing/hair/features, a setting's layout/fixtures/materials). Name the setting by its label (e.g. \"set in <Subject 1>\") instead of describing it; describe only actions, positions, and state changes.\n- HONOR THE BRIEF'S AUDIO: include exactly the sounds, ambience, music, songs, and lyrics the user requests, in the correct section, using their intent verbatim. Never drop them or replace them with different invented audio.\n- If a previous scene is provided (CONTINUATION), write the NEXT scene that continues it: keep continuity of subjects, setting, wardrobe and tone, but SHOW NEW, DIFFERENT action driven by the TASK; do NOT repeat, restate, summarize, or reuse the previous scene's wording, shots, or events. In continuation mode, detailed_description MUST START with exactly \"Continue directly from the incoming H3 Motion Context, \" (this replaces the style/setting opening sentence) and then flow into \"[Shot 1] …\". Otherwise (no previous scene) write a fresh opening scene with the style/setting opening.\n\nDETAILED_DESCRIPTION (main body, playback order; be detailed and explicit — aim for a rich ~350-500 words, but do not pad; dialogue-dense scenes may be shorter to fit the spoken timeline)\n- FORMAT it in TWO parts, in this exact order:\n (1) ONE SHORT opening sentence (≈8–50 words, no [Shot] tag) establishing just the video type/style (from the brief; do NOT default to \"cinematic\" unless asked) and the setting. Keep it brief — the detail belongs in the shot, not here. If any subject_definition is a place/room/environment, that is the SETTING — reference it by its <Subject N> label, don't describe it (e.g. \"Amateur home-video set in <Subject 1>.\").\n (2) On the NEXT line, start with the literal token \"[Shot 1]\" (no timestamp) and describe the shot: composition, subjects by their <Subject N> labels, action, camera, and synchronized sound.\n Never merge the two: the style+setting sentence comes first, THEN \"[Shot 1] ...\". Never start detailed_description directly with \"[Shot 1]\".\n- DEFAULT: ONE shot only. Use \"[Shot 1]\" with NO timestamp, keep the whole scene inside it, and move the camera instead of cutting. Do not add more shots or any timestamps.\n- Multiple shots ONLY if the brief explicitly asks: then add \"[Shot 2]\", \"[Shot 3]\", … each starting with a strictly increasing cut time (e.g. \"At 00:03.500, the camera cuts to …\").\n- Per shot, ground every detail in what is visible or audible: composition, subject position and action, environment and lighting (if the setting is a defined subject, name it by its <Subject N> label instead of re-describing it), state changes, camera movement, and synchronized sound/dialogue; note where each <Subject N>/<Picture N> appears or takes effect. Do NOT write a plot summary or a bare list of reference relationships. Cite a frame anchor naturally (e.g. \"the shot begins from <Picture 1>\").\n- Keep EVERY on-screen subject alive: give each present <Subject N> believable ongoing behavior — small idle actions, natural reactions, gaze/weight shifts, breathing, fidgets, gestures — so no one stands frozen while another acts (unless stillness is clearly intended). Secondary subjects should react to the main action instead of staying static.\n\nCAMERA — natural English within the sentence, never stacked labels. Form: motion (+ \"with small/large amplitude\") (+ \"at slow/fast speed\"); omit medium amplitude and normal speed.\nMotions: Zoom In/Out, Push In/Pull Out, Pan Left/Right, Truck Left/Right, Tilt Up/Down, Pedestal Up/Down, Arc Shot, Tracking Shot, Static Shot, Shake Slightly/Strongly, POV, Roll Clockwise/Counterclockwise.\ne.g. \"The camera pushes in with small amplitude at slow speed toward her hands.\"\n\nDIALOGUE & MUSIC\n- Vocal sources get stable IDs (S1), (S2)...; joint speech (S1,S2). When a defined subject speaks, write \"<Subject N> (Sx)\"; for a speaker who is not a defined subject, use a short stable voice description + (Sx). Keep identity/action OUTSIDE <d>; inside only <d>[Language] exact words</d>, verbatim (no translation).\n- Voiceover: \"says in an off-screen voiceover\" and note the lips stay closed.\n- Music the characters can hear (ambient or from a device) is DIEGETIC → describe it in detailed_description (genre + source; reused lyrics in <d>[Language] ...</d>), NOT in non_diegetic_music. On-screen text in double quotes, verbatim.\n\nOVERALL_SOUNDSCAPE: 1-4 English sentences summarizing WHOLE-VIDEO ambience and general physical / non-verbal sounds (wind, footsteps, impacts, breathing...). Keep it GENERAL: do NOT reference <Subject N> labels, name a shot, or narrate sounds synchronized to a specific action — those action-synced sounds belong in detailed_description. Reflect any ambience the brief requests. No dialogue, songs, or music here. Use \"N/A\" only for deliberate full silence.\n\nNON_DIEGETIC_MUSIC: By DEFAULT write exactly \"N/A\". Only when the brief explicitly asks for background/score music, describe that audience-only music in 1-3 English sentences (instrumentation, tempo, rhythm, dynamics; no mood words).\n\nOutput ONLY these four sections — no preamble, no commentary, no markdown.", | |
| "user": "Generate a complete, vivid scene.", | |
| }, | |
| }; | |
| exports.DEFAULT_PROMPTS = DEFAULT_PROMPTS; | |
| }); | |
| register("js/globals-ai.js", function (module, exports, require) { | |
| const { chat } = require("js/llm/llm-client.js"); | |
| const { isLlmOnline } = require("js/llm/llm-config.js"); | |
| const { listTemplates, getSelectedTemplateId, setSelectedTemplate, getTemplateValue, getTemplateModel } = require("js/llm/llm-prompts.js"); | |
| const { openModal } = require("js/modal.js"); | |
| const { SECTION_HELP } = require("js/section-help.js"); | |
| const { toast } = require("js/toast.js"); | |
| /** | |
| * "Generate globals with AI" for MiniMax H3 Ref2VA prompt-level sections. | |
| * | |
| * Per added reference the user picks a label type (Subject/Picture), a | |
| * relationship marker, and a disk image. The image is sent to a vision model, | |
| * which returns one `<Label N> is …` line for `subject_definitions`; a matching | |
| * `retention_analysis` entry is created DETERMINISTICALLY (no LLM) with the | |
| * chosen marker and a `- TODO` to complete. | |
| * | |
| * The two textareas are the source of truth. A live list is derived from them; | |
| * deleting/reordering in the list re-serializes BOTH fields with sequential | |
| * per-type indices, so numbering always stays in sync. Editing the textareas by | |
| * hand is allowed — the list re-derives from them; any list operation then | |
| * re-numbers, and non-conforming lines are dropped (the user's responsibility). | |
| */ | |
| const $ = (id) => document.getElementById(id); | |
| const SUBJECT_PURPOSE = "library.h3.subject"; | |
| const GEN_TIMEOUT_MS = 120000; | |
| /** Fixed relationship markers for visible content (guide §4.1). */ | |
| const RELATIONSHIP_MARKERS = [ | |
| "fully_preserved", | |
| "partially_preserved", | |
| "attribute_transfer", | |
| "weak_reference", | |
| ]; | |
| const LABEL_RE = /^\s*<(Subject|Picture)\s+(\d+)>\s?(.*)$/; | |
| let el; | |
| /** @type {((v:{subjectDefs:string,retention:string})=>void)|null} */ | |
| let applyFn = null; | |
| /** @type {AbortController|null} */ | |
| let inflight = null; | |
| function initGlobalsAi() { | |
| el = { | |
| root: $("globalsGenModal"), | |
| closeBtn: $("globalsGenCloseBtn"), | |
| template: $("globalsTemplate"), | |
| labelType: $("globalsLabelType"), | |
| nextLabel: $("globalsNextLabel"), | |
| marker: $("globalsMarker"), | |
| imageInput: $("globalsImageInput"), | |
| addBtn: $("globalsAddBtn"), | |
| stopBtn: $("globalsStopBtn"), | |
| status: $("globalsStatus"), | |
| list: $("globalsList"), | |
| listEmpty: $("globalsListEmpty"), | |
| subjects: $("globalsSubjects"), | |
| subjHelp: $("globalsSubjHelp"), | |
| retention: $("globalsRetention"), | |
| retHelp: $("globalsRetHelp"), | |
| acceptBtn: $("globalsAcceptBtn"), | |
| }; | |
| for (const m of RELATIONSHIP_MARKERS) { | |
| const o = document.createElement("option"); | |
| o.value = m; | |
| o.textContent = m; | |
| el.marker.appendChild(o); | |
| } | |
| el.closeBtn.addEventListener("click", close); | |
| el.root.querySelector(".modal-backdrop").addEventListener("click", close); | |
| document.addEventListener("keydown", (ev) => { | |
| if (!el.root.hidden && ev.key === "Escape") { | |
| ev.preventDefault(); | |
| close(); | |
| } | |
| }); | |
| // Same field guidance as the scene editor's "?" help buttons. | |
| el.subjHelp.addEventListener("click", () => | |
| openModal({ title: "subject_definitions — guidance", html: SECTION_HELP.subject_definitions }) | |
| ); | |
| el.retHelp.addEventListener("click", () => | |
| openModal({ title: "retention_analysis — guidance", html: SECTION_HELP.retention_analysis }) | |
| ); | |
| el.template.addEventListener("change", () => | |
| setSelectedTemplate(SUBJECT_PURPOSE, el.template.value) | |
| ); | |
| el.labelType.addEventListener("change", updateNextLabel); | |
| el.imageInput.addEventListener("change", () => { | |
| el.addBtn.disabled = el.imageInput.files.length === 0; | |
| }); | |
| el.addBtn.addEventListener("click", addReference); | |
| el.stopBtn.addEventListener("click", abortInflight); | |
| el.acceptBtn.addEventListener("click", accept); | |
| // Editing subjects by hand drives retention: mirror it line-for-line as you | |
| // type (without rewriting the field you're in), then re-index on blur. | |
| el.subjects.addEventListener("input", onSubjectsInput); | |
| el.subjects.addEventListener("change", normalize); | |
| el.retention.addEventListener("input", renderList); | |
| } | |
| /** As the user edits subject_definitions, keep retention_analysis in sync. */ | |
| function onSubjectsInput() { | |
| syncRetentionToSubjects(); | |
| renderList(); | |
| } | |
| /** | |
| * Rebuild retention_analysis to have exactly one line per subject label present | |
| * in subject_definitions (same labels and order), keeping each label's existing | |
| * retention text and defaulting any new one. Does not rewrite subjects, so | |
| * typing there is never disrupted. Deleting a subject line thus drops its | |
| * retention line too. | |
| */ | |
| function syncRetentionToSubjects() { | |
| const retByLabel = new Map(); | |
| for (const line of el.retention.value.split("\n")) { | |
| const m = line.match(LABEL_RE); | |
| if (m) retByLabel.set(`${m[1]} ${m[2]}`, m[3]); | |
| } | |
| const out = []; | |
| for (const line of el.subjects.value.split("\n")) { | |
| const m = line.match(LABEL_RE); | |
| if (!m) continue; | |
| const label = `<${m[1]} ${m[2]}>`; | |
| const rest = retByLabel.get(`${m[1]} ${m[2]}`) ?? retentionRest(m[1], "fully_preserved"); | |
| out.push(`${label} ${rest}`.trimEnd()); | |
| } | |
| el.retention.value = out.join("\n"); | |
| } | |
| /** Re-serialize both fields with sequential per-type indices (closes gaps). */ | |
| function normalize() { | |
| serializeItems(parseItems()); | |
| } | |
| /** | |
| * @param {{ subjectDefs?:string, retention?:string, | |
| * onApply:(v:{subjectDefs:string,retention:string})=>void }} opts | |
| */ | |
| function openGlobalsAi({ subjectDefs = "", retention = "", onApply }) { | |
| if (!isLlmOnline()) { | |
| toast("AI not connected — check AI settings.", "warn"); | |
| return; | |
| } | |
| applyFn = onApply; | |
| el.subjects.value = subjectDefs; | |
| el.retention.value = retention; | |
| populateTemplateSelect(); | |
| el.imageInput.value = ""; | |
| el.addBtn.disabled = true; | |
| renderList(); | |
| setStatus("", ""); | |
| el.root.hidden = false; | |
| } | |
| function close() { | |
| abortInflight(); | |
| el.root.hidden = true; | |
| } | |
| function populateTemplateSelect() { | |
| el.template.textContent = ""; | |
| for (const t of listTemplates(SUBJECT_PURPOSE)) { | |
| const o = document.createElement("option"); | |
| o.value = t.id; | |
| o.textContent = t.name; | |
| el.template.appendChild(o); | |
| } | |
| el.template.value = getSelectedTemplateId(SUBJECT_PURPOSE); | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Items <-> text (the textareas are the source of truth) | |
| // --------------------------------------------------------------------------- | |
| /** Parse the textareas into items: subject lines drive them, retention matched by label. */ | |
| function parseItems() { | |
| const retByLabel = new Map(); | |
| for (const line of el.retention.value.split("\n")) { | |
| const m = line.match(LABEL_RE); | |
| if (m) retByLabel.set(`${m[1]} ${m[2]}`, m[3]); | |
| } | |
| const items = []; | |
| for (const line of el.subjects.value.split("\n")) { | |
| const m = line.match(LABEL_RE); | |
| if (!m) continue; // non-conforming lines aren't managed by the list | |
| const type = m[1]; | |
| const rest = retByLabel.get(`${m[1]} ${m[2]}`); | |
| items.push({ | |
| type, | |
| subjectBody: m[3], | |
| retentionRest: rest != null ? rest : retentionRest(type, "fully_preserved"), | |
| }); | |
| } | |
| return items; | |
| } | |
| /** Write items back to both textareas with sequential per-type indices. */ | |
| function serializeItems(items) { | |
| const counts = { Subject: 0, Picture: 0 }; | |
| const subj = []; | |
| const ret = []; | |
| for (const it of items) { | |
| counts[it.type] += 1; | |
| const label = `<${it.type} ${counts[it.type]}>`; | |
| subj.push(`${label} ${it.subjectBody}`.trimEnd()); | |
| ret.push(`${label} ${it.retentionRest}`.trimEnd()); | |
| } | |
| el.subjects.value = subj.join("\n"); | |
| el.retention.value = ret.join("\n"); | |
| renderList(); | |
| } | |
| function retentionRest(type, marker) { | |
| const loc = type === "Subject" ? "(appears in [Shot 1])" : "([Shot 1] first frame)"; | |
| return `${loc}: ${marker} - TODO`; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // List UI | |
| // --------------------------------------------------------------------------- | |
| function renderList() { | |
| const items = parseItems(); | |
| el.list.textContent = ""; | |
| el.listEmpty.hidden = items.length > 0; | |
| const counts = { Subject: 0, Picture: 0 }; | |
| items.forEach((it, i) => { | |
| counts[it.type] += 1; | |
| const label = `<${it.type} ${counts[it.type]}>`; | |
| const row = document.createElement("div"); | |
| row.className = "gen-item"; | |
| const up = mkMini("↑", "Move up", () => move(i, -1)); | |
| up.disabled = i === 0; | |
| const down = mkMini("↓", "Move down", () => move(i, 1)); | |
| down.disabled = i === items.length - 1; | |
| const lab = document.createElement("span"); | |
| lab.className = "gen-item-label"; | |
| lab.textContent = label; | |
| const preview = document.createElement("span"); | |
| preview.className = "gen-item-preview"; | |
| preview.textContent = it.subjectBody; | |
| preview.title = it.subjectBody; | |
| const del = mkMini("✕", "Remove", () => remove(i)); | |
| del.classList.add("danger"); | |
| row.append(up, down, lab, preview, del); | |
| el.list.appendChild(row); | |
| }); | |
| updateNextLabel(items); | |
| } | |
| function mkMini(text, title, onClick) { | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.className = "btn gen-item-btn"; | |
| b.textContent = text; | |
| b.title = title; | |
| b.addEventListener("click", onClick); | |
| return b; | |
| } | |
| function remove(i) { | |
| const items = parseItems(); | |
| items.splice(i, 1); | |
| serializeItems(items); | |
| } | |
| function move(i, dir) { | |
| const items = parseItems(); | |
| const to = i + dir; | |
| if (to < 0 || to >= items.length) return; | |
| [items[i], items[to]] = [items[to], items[i]]; | |
| serializeItems(items); | |
| } | |
| function updateNextLabel(items = parseItems()) { | |
| const type = el.labelType.value; | |
| const n = items.filter((it) => it.type === type).length + 1; | |
| el.nextLabel.textContent = `<${type} ${n}>`; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Add a reference (image -> subject line via LLM, + deterministic retention) | |
| // --------------------------------------------------------------------------- | |
| async function addReference() { | |
| const file = el.imageInput.files[0]; | |
| if (!file) { | |
| setStatus("Choose an image first.", "warn"); | |
| return; | |
| } | |
| const type = el.labelType.value; | |
| const marker = el.marker.value; | |
| const label = el.nextLabel.textContent; // for the model's phrasing only | |
| abortInflight(); | |
| const ctrl = new AbortController(); | |
| inflight = ctrl; | |
| const timer = setTimeout(() => ctrl.abort(), GEN_TIMEOUT_MS); | |
| setBusy(true); | |
| setStatus(`Generating ${label}…`, ""); | |
| try { | |
| const dataUrl = await fileToDataUrl(file); | |
| const tid = el.template.value || getSelectedTemplateId(SUBJECT_PURPOSE); | |
| const system = getTemplateValue(SUBJECT_PURPOSE, tid, "system"); | |
| const user = getTemplateValue(SUBJECT_PURPOSE, tid, "user"); | |
| const text = await chat( | |
| [ | |
| { role: "system", content: system }, | |
| { | |
| role: "user", | |
| content: [ | |
| { | |
| type: "text", | |
| text: `${user}\n\nTarget label: ${label}. Output exactly one line starting with "${label} is ".`, | |
| }, | |
| { type: "image_url", image_url: { url: dataUrl } }, | |
| ], | |
| }, | |
| ], | |
| { signal: ctrl.signal, model: getTemplateModel(SUBJECT_PURPOSE, tid) } | |
| ); | |
| const body = text | |
| .trim() | |
| .replace(/\r?\n+/g, " ") | |
| .replace(LABEL_RE, "$3"); // strip any label the model included | |
| if (!body) { | |
| setStatus("The model returned an empty response.", "warn"); | |
| return; | |
| } | |
| const items = parseItems(); | |
| items.push({ type, subjectBody: body, retentionRest: retentionRest(type, marker) }); | |
| serializeItems(items); // re-indexes everything and re-renders the list | |
| el.imageInput.value = ""; | |
| el.addBtn.disabled = true; | |
| setStatus("Added.", "ok"); | |
| } catch (err) { | |
| setStatus(err.message, "error"); | |
| } finally { | |
| clearTimeout(timer); | |
| inflight = null; | |
| setBusy(false); | |
| } | |
| } | |
| function accept() { | |
| applyFn?.({ subjectDefs: el.subjects.value, retention: el.retention.value }); | |
| toast("Globals applied — review and save."); | |
| close(); | |
| } | |
| function abortInflight() { | |
| if (inflight) { | |
| inflight.abort(); | |
| inflight = null; | |
| } | |
| } | |
| function setBusy(busy) { | |
| el.stopBtn.hidden = !busy; | |
| el.addBtn.disabled = busy || el.imageInput.files.length === 0; | |
| el.labelType.disabled = busy; | |
| el.marker.disabled = busy; | |
| el.template.disabled = busy; | |
| } | |
| function setStatus(text, kind) { | |
| el.status.textContent = text; | |
| el.status.className = "llm-status" + (kind ? " " + kind : ""); | |
| } | |
| function fileToDataUrl(file) { | |
| return new Promise((resolve, reject) => { | |
| const r = new FileReader(); | |
| r.onload = () => resolve(r.result); | |
| r.onerror = () => reject(r.error || new Error("read failed")); | |
| r.readAsDataURL(file); | |
| }); | |
| } | |
| exports.RELATIONSHIP_MARKERS = RELATIONSHIP_MARKERS; | |
| exports.initGlobalsAi = initGlobalsAi; | |
| exports.openGlobalsAi = openGlobalsAi; | |
| }); | |
| register("js/review-ai.js", function (module, exports, require) { | |
| const { chat } = require("js/llm/llm-client.js"); | |
| const { isLlmOnline } = require("js/llm/llm-config.js"); | |
| const { listTemplates, getSelectedTemplateId, setSelectedTemplate, getTemplateValue, getTemplateModel } = require("js/llm/llm-prompts.js"); | |
| const { toast } = require("js/toast.js"); | |
| /** | |
| * "Review with AI" for the Prompt Library MiniMax H3 Ref2VA type. One modal, | |
| * two modes selected by the caller: | |
| * | |
| * - WHOLE PROMPT (advisory): the shared subject_definitions plus, per scene, its | |
| * summary and detailed_description, so the model spots cross-scene consistency | |
| * problems. Read-only report — nothing is applied. (purpose only, no rewrite.) | |
| * | |
| * - ONE SCENE (advisory + apply): the current scene plus its neighbors and the | |
| * globals, reviewed for consistency; then "Apply suggestions" makes a SECOND | |
| * call that rewrites the scene implementing the notes, shown for confirmation | |
| * before "Accept into scene" applies it. Enabled by passing `rewrite`. | |
| * | |
| * The caller assembles the context and, for scene mode, owns parse+apply through | |
| * `rewrite.onApply` — this module stays free of domain parsing (mirrors the way | |
| * text-ai.js hands the raw result back). | |
| */ | |
| const $ = (id) => document.getElementById(id); | |
| const DEFAULT_PURPOSE = "library.h3.review"; | |
| const GEN_TIMEOUT_MS = 180000; // reviews (and rewrites) can be long | |
| let el; | |
| /** | |
| * @type {{ purpose?:string, title?:string, contextLabel?:string, context?:string, | |
| * rewrite?:{ purpose:string, instruction:string, acceptLabel?:string, | |
| * validate?:(t:string)=>boolean, onApply:(t:string)=>void } } | null} | |
| */ | |
| let cfg = null; | |
| /** The assembled context sent with every review turn (set on open). */ | |
| let contextText = ""; | |
| /** @type {AbortController|null} */ | |
| let inflight = null; | |
| /** Review conversation so far (null before the first review). Refine appends turns. */ | |
| let history = null; | |
| /** True once a review result exists (gates "Apply suggestions"). */ | |
| let hasReview = false; | |
| function reviewPurpose() { | |
| return (cfg && cfg.purpose) || DEFAULT_PURPOSE; | |
| } | |
| function initReviewAi() { | |
| el = { | |
| root: $("reviewModal"), | |
| title: $("reviewTitle"), | |
| closeBtn: $("reviewCloseBtn"), | |
| template: $("reviewTemplate"), | |
| system: $("reviewSystem"), | |
| user: $("reviewUser"), | |
| contextLabel: $("reviewContextLabel"), | |
| context: $("reviewContext"), | |
| runBtn: $("reviewRunBtn"), | |
| refineBtn: $("reviewRefineBtn"), | |
| stopBtn: $("reviewStopBtn"), | |
| status: $("reviewStatus"), | |
| result: $("reviewResult"), | |
| applyBtn: $("reviewApplyBtn"), | |
| copyBtn: $("reviewCopyBtn"), | |
| rewriteWrap: $("reviewRewriteWrap"), | |
| rewrite: $("reviewRewrite"), | |
| acceptRow: $("reviewAcceptRow"), | |
| acceptBtn: $("reviewAcceptBtn"), | |
| }; | |
| el.closeBtn.addEventListener("click", close); | |
| el.root.querySelector(".modal-backdrop").addEventListener("click", close); | |
| document.addEventListener("keydown", (ev) => { | |
| if (!el.root.hidden && ev.key === "Escape") { | |
| ev.preventDefault(); | |
| close(); | |
| } | |
| }); | |
| el.template.addEventListener("change", onTemplateChange); | |
| el.runBtn.addEventListener("click", run); | |
| el.refineBtn.addEventListener("click", refine); | |
| el.stopBtn.addEventListener("click", abortInflight); | |
| el.result.addEventListener("input", syncCopy); | |
| el.copyBtn.addEventListener("click", copy); | |
| el.applyBtn.addEventListener("click", applySuggestions); | |
| el.rewrite.addEventListener("input", () => syncAccept()); | |
| el.acceptBtn.addEventListener("click", accept); | |
| } | |
| /** | |
| * @param {{ purpose?:string, title?:string, contextLabel?:string, context:string, | |
| * rewrite?:{ purpose:string, instruction:string, acceptLabel?:string, | |
| * validate?:(t:string)=>boolean, onApply:(t:string)=>void } }} opts | |
| */ | |
| function openReviewAi(opts) { | |
| if (!isLlmOnline()) { | |
| toast("AI not connected — check AI settings.", "warn"); | |
| return; | |
| } | |
| cfg = opts; | |
| el.title.textContent = opts.title || "Review prompt with AI"; | |
| el.contextLabel.textContent = opts.contextLabel || "Prompt under review (read-only)"; | |
| contextText = (opts.context || "").trim(); | |
| el.context.value = contextText; | |
| populateTemplateSelect(); | |
| const sel = getSelectedTemplateId(reviewPurpose()); | |
| el.template.value = sel; | |
| applyTemplate(sel); | |
| history = null; // fresh session | |
| hasReview = false; | |
| el.result.value = ""; | |
| el.rewrite.value = ""; | |
| el.runBtn.textContent = "Review"; | |
| el.refineBtn.hidden = true; // appears after the first review | |
| const sceneMode = !!opts.rewrite; | |
| el.applyBtn.hidden = !sceneMode; // only scene mode can implement suggestions | |
| el.acceptBtn.textContent = (opts.rewrite && opts.rewrite.acceptLabel) || "Accept into scene"; | |
| el.rewriteWrap.hidden = true; // revealed once a corrected scene is produced | |
| el.acceptRow.hidden = true; | |
| setBusy(false); | |
| setStatus("Ready.", ""); | |
| el.root.hidden = false; | |
| el.user.focus(); | |
| } | |
| function close() { | |
| abortInflight(); | |
| el.root.hidden = true; | |
| } | |
| function populateTemplateSelect() { | |
| el.template.textContent = ""; | |
| for (const t of listTemplates(reviewPurpose())) { | |
| const o = document.createElement("option"); | |
| o.value = t.id; | |
| o.textContent = t.name; | |
| el.template.appendChild(o); | |
| } | |
| } | |
| function applyTemplate(id) { | |
| el.system.value = getTemplateValue(reviewPurpose(), id, "system"); | |
| el.user.value = getTemplateValue(reviewPurpose(), id, "user"); | |
| } | |
| function onTemplateChange() { | |
| applyTemplate(el.template.value); | |
| setSelectedTemplate(reviewPurpose(), el.template.value); | |
| } | |
| /** | |
| * Low-level chat call shared by review, refine and rewrite. Owns the inflight/ | |
| * timeout/busy plumbing; returns the assistant text, or null on error/abort. | |
| */ | |
| async function callModel(messages, model, verb) { | |
| abortInflight(); | |
| const ctrl = new AbortController(); | |
| inflight = ctrl; | |
| const timer = setTimeout(() => ctrl.abort(), GEN_TIMEOUT_MS); | |
| setBusy(true); | |
| setStatus(verb, ""); | |
| try { | |
| const text = await chat(messages, { signal: ctrl.signal, model }); | |
| return (text || "").trim(); | |
| } catch (err) { | |
| setStatus(err.message, "error"); | |
| return null; | |
| } finally { | |
| clearTimeout(timer); | |
| inflight = null; | |
| setBusy(false); | |
| } | |
| } | |
| /** First-turn review messages: system + (context, then the brief LAST). */ | |
| function buildReviewMessages() { | |
| const parts = []; | |
| if (contextText) parts.push(`UNDER REVIEW:\n${contextText}`); | |
| if (el.user.value.trim()) parts.push(`TASK (do this now):\n${el.user.value.trim()}`); | |
| const messages = []; | |
| if (el.system.value.trim()) messages.push({ role: "system", content: el.system.value }); | |
| messages.push({ role: "user", content: parts.join("\n\n") || el.user.value }); | |
| return messages; | |
| } | |
| /** Once a review exists, reveal Refine, relabel the primary button, allow Apply. */ | |
| function afterReviewed() { | |
| el.runBtn.textContent = "Re-review"; | |
| el.refineBtn.hidden = false; | |
| } | |
| /** Store the completed review result and status. */ | |
| function setReviewResult(text) { | |
| el.result.value = text; | |
| setStatus( | |
| text ? "Done." : "The model returned an empty response.", | |
| text ? "ok" : "warn" | |
| ); | |
| hasReview = true; | |
| syncButtons(); | |
| } | |
| /** Fresh review: rebuild from the fields + context, discarding any prior turns. */ | |
| async function run() { | |
| const messages = buildReviewMessages(); | |
| const text = await callModel(messages, getTemplateModel(reviewPurpose(), el.template.value), "Reviewing…"); | |
| if (text == null) return; | |
| setReviewResult(text); | |
| history = [...messages, { role: "assistant", content: text }]; | |
| afterReviewed(); | |
| } | |
| /** Refine: keep the conversation and send the current user prompt as a new turn. */ | |
| async function refine() { | |
| if (!history) return run(); | |
| const base = history.slice(); | |
| if (base.length && base[base.length - 1].role === "assistant") { | |
| base[base.length - 1] = { role: "assistant", content: el.result.value }; | |
| } | |
| const instruction = el.user.value.trim() || "Refine the previous review."; | |
| const messages = [...base, { role: "user", content: instruction }]; | |
| const text = await callModel(messages, getTemplateModel(reviewPurpose(), el.template.value), "Reviewing…"); | |
| if (text == null) return; | |
| setReviewResult(text); | |
| history = [...messages, { role: "assistant", content: text }]; | |
| afterReviewed(); | |
| } | |
| /** | |
| * Scene mode only: a SECOND call that rewrites the scene implementing the review | |
| * notes. Uses the rewrite purpose's own system (canonical scene format) rather | |
| * than the reviewer's, and hands the result to the "Corrected scene" field for | |
| * confirmation. Nothing is applied until Accept. | |
| */ | |
| async function applySuggestions() { | |
| if (!cfg || !cfg.rewrite || !hasReview) return; | |
| const rp = cfg.rewrite.purpose; | |
| const tid = getSelectedTemplateId(rp); | |
| const system = getTemplateValue(rp, tid, "system"); | |
| const parts = []; | |
| if (contextText) parts.push(contextText); | |
| if (el.result.value.trim()) parts.push(`REVIEW NOTES (apply these fixes):\n${el.result.value.trim()}`); | |
| parts.push(cfg.rewrite.instruction); | |
| const messages = []; | |
| if (system.trim()) messages.push({ role: "system", content: system }); | |
| messages.push({ role: "user", content: parts.join("\n\n") }); | |
| const text = await callModel(messages, getTemplateModel(rp, tid), "Applying suggestions…"); | |
| if (text == null) return; | |
| el.rewrite.value = text; | |
| el.rewriteWrap.hidden = false; | |
| el.acceptRow.hidden = false; | |
| setStatus( | |
| text ? "Corrected scene ready — review it, then Accept." : "The model returned an empty response.", | |
| text ? "ok" : "warn" | |
| ); | |
| syncButtons(); | |
| el.rewrite.scrollIntoView?.({ block: "nearest" }); | |
| } | |
| function accept() { | |
| const text = el.rewrite.value; | |
| if (!text.trim() || !cfg || !cfg.rewrite) return; | |
| cfg.rewrite.onApply?.(text); | |
| close(); | |
| } | |
| async function copy() { | |
| try { | |
| await navigator.clipboard.writeText(el.result.value); | |
| toast("Review copied."); | |
| } catch { | |
| toast("Clipboard blocked by the browser.", "warn"); | |
| } | |
| } | |
| function abortInflight() { | |
| if (inflight) { | |
| inflight.abort(); | |
| inflight = null; | |
| } | |
| } | |
| function setBusy(busy) { | |
| el.stopBtn.hidden = !busy; | |
| el.runBtn.disabled = busy; | |
| el.refineBtn.disabled = busy; | |
| syncButtons(busy); | |
| } | |
| /** Reconcile Apply / Accept / Copy with the current mode and state. */ | |
| function syncButtons(busy = false) { | |
| const sceneMode = !!(cfg && cfg.rewrite); | |
| el.applyBtn.disabled = busy || !sceneMode || !hasReview; | |
| syncAccept(busy); | |
| syncCopy(); | |
| } | |
| function syncAccept(busy = false) { | |
| const sceneMode = !!(cfg && cfg.rewrite); | |
| const text = el.rewrite.value; | |
| const valid = | |
| sceneMode && | |
| text.trim().length > 0 && | |
| (!cfg.rewrite.validate || cfg.rewrite.validate(text)); | |
| el.acceptBtn.disabled = busy || !valid; | |
| } | |
| function syncCopy() { | |
| el.copyBtn.disabled = el.result.value.trim().length === 0; | |
| } | |
| function setStatus(text, kind) { | |
| el.status.textContent = text; | |
| el.status.className = "llm-status" + (kind ? " " + kind : ""); | |
| } | |
| exports.initReviewAi = initReviewAi; | |
| exports.openReviewAi = openReviewAi; | |
| }); | |
| register("js/llm/llm-settings.js", function (module, exports, require) { | |
| const { llmConfig, saveLlmConfig, DEFAULT_LLM_CONFIG, isLlmConfigured, setLlmStatus } = require("js/llm/llm-config.js"); | |
| const { listModels, testConnection } = require("js/llm/llm-client.js"); | |
| const { openPromptTemplates } = require("js/llm/llm-prompt-templates.js"); | |
| const { toast } = require("js/toast.js"); | |
| /** | |
| * The global "AI settings" dialog: edits the app-wide `llmConfig` (base URL, | |
| * model, API key, temperature). Opened from the top nav; every feature that uses | |
| * the LLM relies on what is configured here. | |
| */ | |
| const $ = (id) => document.getElementById(id); | |
| let el; | |
| /** How long a probe may run before we abort it and report a timeout. */ | |
| const TEST_TIMEOUT_MS = 15000; // explicit Test / Refresh (user is waiting) | |
| const STATUS_TIMEOUT_MS = 8000; // background nav indicator | |
| /** An AbortSignal that fires after `ms`; call clear() when the work is done. */ | |
| function timeoutSignal(ms) { | |
| const ctrl = new AbortController(); | |
| const timer = setTimeout(() => ctrl.abort(), ms); | |
| return { signal: ctrl.signal, clear: () => clearTimeout(timer) }; | |
| } | |
| function initLlmSettings() { | |
| el = { | |
| root: $("llmModal"), | |
| closeBtn: $("llmCloseBtn"), | |
| baseUrl: $("llmBaseUrl"), | |
| model: $("llmModel"), | |
| modelList: $("llmModelList"), | |
| apiKey: $("llmApiKey"), | |
| temperature: $("llmTemperature"), | |
| independent: $("llmIndependent"), | |
| refreshBtn: $("llmRefreshBtn"), | |
| testBtn: $("llmTestBtn"), | |
| status: $("llmStatus"), | |
| templatesBtn: $("llmTemplatesBtn"), | |
| saveBtn: $("llmSaveBtn"), | |
| }; | |
| el.closeBtn.addEventListener("click", closeLlmSettings); | |
| el.root.querySelector(".modal-backdrop").addEventListener("click", closeLlmSettings); | |
| document.addEventListener("keydown", (ev) => { | |
| if (el.root.hidden || ev.key !== "Escape") return; | |
| // The templates manager opens on top of this dialog; let it handle Escape. | |
| const templates = $("promptTemplatesModal"); | |
| if (templates && !templates.hidden) return; | |
| ev.preventDefault(); | |
| closeLlmSettings(); | |
| }); | |
| el.refreshBtn.addEventListener("click", refreshModels); | |
| el.testBtn.addEventListener("click", test); | |
| el.templatesBtn.addEventListener("click", openPromptTemplates); | |
| el.saveBtn.addEventListener("click", save); | |
| } | |
| function openLlmSettings() { | |
| populate(); | |
| el.root.hidden = false; | |
| el.baseUrl.focus(); | |
| } | |
| function closeLlmSettings() { | |
| el.root.hidden = true; | |
| } | |
| /** Reflect the current config into the form fields. */ | |
| function populate() { | |
| el.baseUrl.value = llmConfig.baseUrl || DEFAULT_LLM_CONFIG.baseUrl; | |
| el.model.value = llmConfig.model || ""; | |
| el.apiKey.value = llmConfig.apiKey || ""; | |
| el.temperature.value = | |
| llmConfig.temperature ?? DEFAULT_LLM_CONFIG.temperature; | |
| el.independent.checked = llmConfig.independentContext !== false; | |
| setStatus("", ""); | |
| } | |
| /** Read the form into a config patch (base URL is normalized on save). */ | |
| function readForm() { | |
| const t = parseFloat(el.temperature.value); | |
| return { | |
| baseUrl: el.baseUrl.value, | |
| model: el.model.value.trim(), | |
| apiKey: el.apiKey.value, | |
| temperature: Number.isFinite(t) ? t : DEFAULT_LLM_CONFIG.temperature, | |
| independentContext: el.independent.checked, | |
| }; | |
| } | |
| function setStatus(text, kind) { | |
| el.status.textContent = text; | |
| el.status.className = "llm-status" + (kind ? " " + kind : ""); | |
| } | |
| /** Fill the model datalist from the endpoint, using the URL/key in the form. */ | |
| function fillModelList(models) { | |
| el.modelList.textContent = ""; | |
| for (const id of models) { | |
| const opt = document.createElement("option"); | |
| opt.value = id; | |
| el.modelList.appendChild(opt); | |
| } | |
| } | |
| async function refreshModels() { | |
| await withFormApplied(async () => { | |
| setStatus("Loading models…", ""); | |
| const t = timeoutSignal(TEST_TIMEOUT_MS); | |
| try { | |
| const models = await listModels({ signal: t.signal }); | |
| fillModelList(models); | |
| if (!el.model.value && models.length) el.model.value = models[0]; | |
| setStatus( | |
| models.length ? `Found ${models.length} model(s).` : "No models loaded.", | |
| models.length ? "ok" : "warn" | |
| ); | |
| } catch (err) { | |
| setStatus(err.message, "error"); | |
| } finally { | |
| t.clear(); | |
| } | |
| }); | |
| } | |
| async function test() { | |
| await withFormApplied(async () => { | |
| setStatus("Testing…", ""); | |
| const t = timeoutSignal(TEST_TIMEOUT_MS); | |
| const r = await testConnection({ signal: t.signal }); | |
| t.clear(); | |
| setStatus(r.message, r.ok ? "ok" : "error"); | |
| if (r.ok && r.models.length) fillModelList(r.models); | |
| }); | |
| } | |
| async function save() { | |
| await saveLlmConfig(readForm()); | |
| toast("AI settings saved."); | |
| closeLlmSettings(); | |
| refreshLlmStatus(); // re-probe so the nav indicator reflects the new config | |
| } | |
| /** | |
| * Update the nav "AI settings" status dot to reflect the SAVED config: | |
| * grey = not configured, amber = configured but unreachable / checking, | |
| * green = connected (verified). Safe to call fire-and-forget. | |
| */ | |
| async function refreshLlmStatus() { | |
| const dot = $("llmStatusDot"); | |
| const btn = $("llmSettingsBtn"); | |
| if (!isLlmConfigured()) { | |
| apply(dot, btn, "off", "AI not configured — click to set it up"); | |
| return; | |
| } | |
| apply(dot, btn, "checking", "Checking AI connection…"); | |
| const t = timeoutSignal(STATUS_TIMEOUT_MS); | |
| const r = await testConnection({ signal: t.signal }); | |
| t.clear(); | |
| apply( | |
| dot, | |
| btn, | |
| r.ok ? "on" : "warn", | |
| r.ok | |
| ? `AI connected — ${llmConfig.model} @ ${llmConfig.baseUrl}` | |
| : `AI configured but unreachable: ${r.message}` | |
| ); | |
| } | |
| /** Update the nav dot + tooltip and broadcast the status to feature listeners. */ | |
| function apply(dot, btn, state, title) { | |
| if (dot) dot.className = "llm-dot llm-dot-" + state; | |
| if (btn) btn.title = title; | |
| setLlmStatus(state); | |
| } | |
| /** | |
| * Refresh/Test must use the URL and key the user is currently typing, not just | |
| * the last saved values. Apply the form to `llmConfig` for the duration of the | |
| * probe, then restore the saved state so nothing is persisted until Save. | |
| */ | |
| async function withFormApplied(fn) { | |
| const snapshot = { ...llmConfig }; | |
| Object.assign(llmConfig, readForm()); | |
| try { | |
| await fn(); | |
| } finally { | |
| Object.assign(llmConfig, snapshot); | |
| } | |
| } | |
| exports.initLlmSettings = initLlmSettings; | |
| exports.openLlmSettings = openLlmSettings; | |
| exports.refreshLlmStatus = refreshLlmStatus; | |
| }); | |
| register("js/llm/llm-prompt-templates.js", function (module, exports, require) { | |
| const { PURPOSE_DEFS, DEFAULT_TEMPLATE_ID, getPurpose, promptTree, llmTemplates, saveLlmTemplates } = require("js/llm/llm-prompts.js"); | |
| const { listModels } = require("js/llm/llm-client.js"); | |
| const { toast } = require("js/toast.js"); | |
| /** | |
| * Full-screen "Prompt templates" manager. | |
| * | |
| * Sidebar = purposes (grouped: Image prompts, later Scene / Field prompts). | |
| * Pane = for the active purpose: a template picker (built-in Default + your | |
| * named templates) with New / Duplicate / Delete, then the editable fields. | |
| * The Default is read-only; duplicate it to customize. | |
| * | |
| * All edits happen on a local `draft` (a deep copy of the store) so switching | |
| * purposes/templates never loses changes; Save commits the whole draft. | |
| */ | |
| const $ = (id) => document.getElementById(id); | |
| const uid = () => "t_" + Math.random().toString(36).slice(2, 9); | |
| let el; | |
| /** @type {Record<string, {selected:string, templates:any[]}>} */ | |
| let draft = {}; | |
| let activePurpose = null; | |
| /** Id of the template currently shown in the editor. */ | |
| let viewingId = DEFAULT_TEMPLATE_ID; | |
| function initPromptTemplates() { | |
| el = { | |
| root: $("promptTemplatesModal"), | |
| sidebar: $("ptSidebar"), | |
| pane: $("ptPane"), | |
| modelList: $("ptModelList"), | |
| saveBtn: $("ptSaveBtn"), | |
| closeBtn: $("ptCloseBtn"), | |
| }; | |
| el.closeBtn.addEventListener("click", close); | |
| el.root.querySelector(".modal-backdrop").addEventListener("click", close); | |
| document.addEventListener("keydown", (ev) => { | |
| if (!el.root.hidden && ev.key === "Escape") { | |
| ev.preventDefault(); | |
| close(); | |
| } | |
| }); | |
| el.saveBtn.addEventListener("click", save); | |
| } | |
| function openPromptTemplates() { | |
| draft = JSON.parse(JSON.stringify(llmTemplates)); | |
| for (const p of PURPOSE_DEFS) { | |
| if (!draft[p.id]) draft[p.id] = { selected: DEFAULT_TEMPLATE_ID, templates: [], defaultModel: "" }; | |
| if (!Array.isArray(draft[p.id].templates)) draft[p.id].templates = []; | |
| if (typeof draft[p.id].defaultModel !== "string") draft[p.id].defaultModel = ""; | |
| } | |
| activePurpose = PURPOSE_DEFS[0]?.id || null; | |
| viewingId = DEFAULT_TEMPLATE_ID; | |
| renderSidebar(); | |
| renderPane(); | |
| el.root.hidden = false; | |
| loadModelSuggestions(); // best-effort, fills the per-template model datalist | |
| } | |
| /** Populate the model datalist from the server (best-effort; ignored on error). */ | |
| async function loadModelSuggestions() { | |
| try { | |
| const models = await listModels(); | |
| el.modelList.textContent = ""; | |
| for (const id of models) { | |
| const o = document.createElement("option"); | |
| o.value = id; | |
| el.modelList.appendChild(o); | |
| } | |
| } catch { | |
| /* offline / no server — the model field still works as free text */ | |
| } | |
| } | |
| function close() { | |
| el.root.hidden = true; | |
| } | |
| function renderSidebar() { | |
| el.sidebar.textContent = ""; | |
| for (const { area, groups } of promptTree()) { | |
| const areaEl = document.createElement("div"); | |
| areaEl.className = "pt-area-title"; | |
| areaEl.textContent = area; | |
| el.sidebar.appendChild(areaEl); | |
| for (const g of groups) { | |
| if (g.label) { | |
| const typeEl = document.createElement("div"); | |
| typeEl.className = "pt-type-title"; | |
| typeEl.textContent = g.label; | |
| el.sidebar.appendChild(typeEl); | |
| } | |
| for (const p of g.purposes) { | |
| const item = document.createElement("button"); | |
| item.type = "button"; | |
| item.className = | |
| "pt-sidebar-item" + | |
| (p.id === activePurpose ? " active" : "") + | |
| (g.label ? " pt-nested" : ""); | |
| item.textContent = p.label; | |
| item.addEventListener("click", () => { | |
| activePurpose = p.id; | |
| viewingId = DEFAULT_TEMPLATE_ID; | |
| renderSidebar(); | |
| renderPane(); | |
| }); | |
| el.sidebar.appendChild(item); | |
| } | |
| } | |
| } | |
| } | |
| /** The template object being viewed (a real draft ref for user templates). */ | |
| function viewing() { | |
| const purpose = getPurpose(activePurpose); | |
| if (viewingId === DEFAULT_TEMPLATE_ID) { | |
| return { id: DEFAULT_TEMPLATE_ID, name: "Default", values: { ...purpose.default }, builtin: true }; | |
| } | |
| return draft[activePurpose].templates.find((t) => t.id === viewingId) || null; | |
| } | |
| function mkBtn(text, onClick, opts = {}) { | |
| const b = document.createElement("button"); | |
| b.type = "button"; | |
| b.className = "btn" + (opts.className ? " " + opts.className : ""); | |
| b.textContent = text; | |
| if (opts.disabled) b.disabled = true; | |
| b.addEventListener("click", onClick); | |
| return b; | |
| } | |
| function renderPane() { | |
| el.pane.textContent = ""; | |
| const purpose = getPurpose(activePurpose); | |
| if (!purpose) return; | |
| const title = document.createElement("div"); | |
| title.className = "pt-purpose-title"; | |
| title.textContent = purpose.label; | |
| const desc = document.createElement("p"); | |
| desc.className = "scene-hint"; | |
| desc.textContent = purpose.description || ""; | |
| el.pane.append(title, desc); | |
| // Template picker + actions. | |
| const bar = document.createElement("div"); | |
| bar.className = "pt-tpl-bar"; | |
| const label = document.createElement("span"); | |
| label.className = "llm-label"; | |
| label.textContent = "Template"; | |
| const select = document.createElement("select"); | |
| select.className = "llm-input"; | |
| const list = [ | |
| { id: DEFAULT_TEMPLATE_ID, name: "Default" }, | |
| ...draft[activePurpose].templates, | |
| ]; | |
| for (const t of list) { | |
| const opt = document.createElement("option"); | |
| opt.value = t.id; | |
| opt.textContent = t.name; | |
| select.appendChild(opt); | |
| } | |
| select.value = viewingId; | |
| select.addEventListener("change", () => { | |
| viewingId = select.value; | |
| renderPane(); | |
| }); | |
| const newBtn = mkBtn("+ New", () => { | |
| const id = uid(); | |
| draft[activePurpose].templates.push({ | |
| id, | |
| name: "New template", | |
| values: { ...purpose.default }, | |
| model: "", | |
| }); | |
| viewingId = id; | |
| renderPane(); | |
| }); | |
| const dupBtn = mkBtn("Duplicate", () => { | |
| const cur = viewing(); | |
| const id = uid(); | |
| draft[activePurpose].templates.push({ | |
| id, | |
| name: "Copy of " + cur.name, | |
| values: { ...cur.values }, | |
| model: cur.model || "", | |
| }); | |
| viewingId = id; | |
| renderPane(); | |
| }); | |
| const delBtn = mkBtn( | |
| "Delete", | |
| () => { | |
| if (viewingId === DEFAULT_TEMPLATE_ID) return; | |
| if (!window.confirm("Delete this template?")) return; | |
| const s = draft[activePurpose]; | |
| s.templates = s.templates.filter((t) => t.id !== viewingId); | |
| if (s.selected === viewingId) s.selected = DEFAULT_TEMPLATE_ID; | |
| viewingId = DEFAULT_TEMPLATE_ID; | |
| renderPane(); | |
| }, | |
| { className: "danger", disabled: viewingId === DEFAULT_TEMPLATE_ID } | |
| ); | |
| bar.append(label, select, newBtn, dupBtn, delBtn); | |
| el.pane.append(bar); | |
| const cur = viewing(); | |
| const builtin = !!cur.builtin; | |
| if (builtin) { | |
| const note = document.createElement("p"); | |
| note.className = "scene-hint"; | |
| note.textContent = | |
| "Built-in default — its prompts are read-only, but you can set its Model below (no need to duplicate)."; | |
| el.pane.append(note); | |
| } else { | |
| const nameField = document.createElement("div"); | |
| nameField.className = "pt-field"; | |
| const nlabel = document.createElement("span"); | |
| nlabel.className = "llm-label"; | |
| nlabel.textContent = "Name"; | |
| const nameInput = document.createElement("input"); | |
| nameInput.type = "text"; | |
| nameInput.className = "llm-input"; | |
| nameInput.value = cur.name; | |
| nameInput.addEventListener("input", () => { | |
| cur.name = nameInput.value; | |
| const opt = [...select.options].find((o) => o.value === cur.id); | |
| if (opt) opt.textContent = nameInput.value; // keep the picker label in sync | |
| }); | |
| nameField.append(nlabel, nameInput); | |
| el.pane.append(nameField); | |
| } | |
| // Per-template model (empty = the global model from AI settings). Editable for | |
| // EVERY template, including the built-in Default (via its defaultModel). | |
| const modelField = document.createElement("div"); | |
| modelField.className = "pt-field"; | |
| const mlabel = document.createElement("span"); | |
| mlabel.className = "llm-label"; | |
| mlabel.textContent = "Model"; | |
| const mdesc = document.createElement("p"); | |
| mdesc.className = "scene-hint"; | |
| mdesc.textContent = | |
| "Leave empty to use the global model (AI settings). LM Studio loads it on demand."; | |
| const modelInput = document.createElement("input"); | |
| modelInput.type = "text"; | |
| modelInput.className = "llm-input"; | |
| modelInput.setAttribute("list", "ptModelList"); | |
| modelInput.placeholder = "(global default)"; | |
| modelInput.value = builtin ? draft[activePurpose].defaultModel || "" : cur.model || ""; | |
| modelInput.addEventListener("input", () => { | |
| const v = modelInput.value.trim(); | |
| if (builtin) draft[activePurpose].defaultModel = v; | |
| else cur.model = v; | |
| }); | |
| modelField.append(mlabel, mdesc, modelInput); | |
| el.pane.append(modelField); | |
| for (const f of purpose.fields) { | |
| const field = document.createElement("div"); | |
| field.className = "pt-field"; | |
| const head = document.createElement("div"); | |
| head.className = "pt-field-head"; | |
| const flabel = document.createElement("span"); | |
| flabel.className = "llm-label"; | |
| flabel.textContent = f.label; | |
| head.append(flabel); | |
| const fdesc = document.createElement("p"); | |
| fdesc.className = "scene-hint"; | |
| fdesc.textContent = f.description || ""; | |
| const ta = document.createElement("textarea"); | |
| ta.className = "ai-gen-ta pt-textarea"; | |
| ta.spellcheck = false; | |
| ta.value = cur.values[f.key] ?? purpose.default[f.key] ?? ""; | |
| if (builtin) { | |
| ta.readOnly = true; | |
| } else { | |
| ta.addEventListener("input", () => { | |
| cur.values[f.key] = ta.value; | |
| }); | |
| } | |
| field.append(head, fdesc, ta); | |
| el.pane.append(field); | |
| } | |
| } | |
| async function save() { | |
| await saveLlmTemplates(draft); | |
| toast("Prompt templates saved."); | |
| close(); | |
| } | |
| exports.initPromptTemplates = initPromptTemplates; | |
| exports.openPromptTemplates = openPromptTemplates; | |
| }); | |
| register("js/image-ai.js", function (module, exports, require) { | |
| const { chat } = require("js/llm/llm-client.js"); | |
| const { isLlmOnline } = require("js/llm/llm-config.js"); | |
| const { listTemplates, getSelectedTemplateId, setSelectedTemplate, getTemplateValue, getTemplateModel } = require("js/llm/llm-prompts.js"); | |
| const { toast } = require("js/toast.js"); | |
| /** The prompt purpose this feature uses. */ | |
| const PURPOSE = "image.generate"; | |
| /** | |
| * "Generate with AI" for the Image-prompts section: sends the open image plus a | |
| * system/user prompt to a vision-capable LLM and returns a prompt the user can | |
| * edit, retry, or accept as the image's prompt. | |
| * | |
| * This is a feature module: it owns the #aiGenModal and the generation flow, but | |
| * gets the image file and applies the accepted result through callbacks so it | |
| * stays decoupled from app.js. | |
| */ | |
| const $ = (id) => document.getElementById(id); | |
| /** Abort a generation that hangs (server accepted the request but never replies). */ | |
| const GEN_TIMEOUT_MS = 120000; | |
| let el; | |
| /** @type {{ getFile: (entry:any)=>Promise<File>, applyPrompt: (text:string)=>void }} */ | |
| let deps; | |
| let currentEntry = null; | |
| let currentDataUrl = null; | |
| /** Preview starts hidden for privacy; the image is still sent to the LLM. */ | |
| let imageShown = false; | |
| /** @type {AbortController|null} */ | |
| let inflight = null; | |
| /** Conversation so far (null before the first generation). Refine appends turns. */ | |
| let history = null; | |
| function initImageAi(d) { | |
| deps = d; | |
| el = { | |
| root: $("aiGenModal"), | |
| closeBtn: $("aiGenCloseBtn"), | |
| img: $("aiGenImg"), | |
| toggleImgBtn: $("aiGenToggleImgBtn"), | |
| imgName: $("aiGenImgName"), | |
| template: $("aiGenTemplate"), | |
| system: $("aiGenSystem"), | |
| user: $("aiGenUser"), | |
| runBtn: $("aiGenRunBtn"), | |
| refineBtn: $("aiGenRefineBtn"), | |
| stopBtn: $("aiGenStopBtn"), | |
| status: $("aiGenStatus"), | |
| result: $("aiGenResult"), | |
| acceptBtn: $("aiGenAcceptBtn"), | |
| }; | |
| el.closeBtn.addEventListener("click", close); | |
| el.root.querySelector(".modal-backdrop").addEventListener("click", close); | |
| document.addEventListener("keydown", (ev) => { | |
| if (!el.root.hidden && ev.key === "Escape") { | |
| ev.preventDefault(); | |
| close(); | |
| } | |
| }); | |
| el.runBtn.addEventListener("click", run); // "Generate", relabels to "Regenerate" | |
| el.refineBtn.addEventListener("click", refine); | |
| el.stopBtn.addEventListener("click", abortInflight); | |
| el.acceptBtn.addEventListener("click", accept); | |
| el.toggleImgBtn.addEventListener("click", toggleImage); | |
| el.template.addEventListener("change", onTemplateChange); | |
| el.result.addEventListener("input", () => syncButtons()); | |
| } | |
| async function openImageAi(entry) { | |
| if (!entry) return; | |
| if (!isLlmOnline()) { | |
| toast("AI not connected — check AI settings.", "warn"); | |
| return; | |
| } | |
| currentEntry = entry; | |
| currentDataUrl = null; | |
| imageShown = false; // always start hidden for privacy | |
| // Populate the template picker (built-in Default + your saved templates), | |
| // preselect the last-used one, and prefill the fields from it. The prompts | |
| // stay editable per generation without changing the saved template. | |
| populateTemplateSelect(); | |
| const sel = getSelectedTemplateId(PURPOSE); | |
| el.template.value = sel; | |
| applyTemplate(sel); | |
| el.result.value = ""; | |
| history = null; // fresh session | |
| el.runBtn.textContent = "Generate"; | |
| el.refineBtn.hidden = true; | |
| el.imgName.textContent = entry.name; | |
| syncImageToggle(); | |
| setBusy(false); | |
| setStatus("Loading image…", ""); | |
| el.root.hidden = false; | |
| try { | |
| const file = await deps.getFile(entry); | |
| currentDataUrl = await fileToDataUrl(file); | |
| syncImageToggle(); // reveal now only if the user already asked to show it | |
| setStatus("Ready.", ""); | |
| } catch (err) { | |
| setStatus("Could not load image: " + err.message, "error"); | |
| } | |
| syncButtons(); | |
| } | |
| /** Fill the template <select> with Default + the user's saved templates. */ | |
| function populateTemplateSelect() { | |
| el.template.textContent = ""; | |
| for (const t of listTemplates(PURPOSE)) { | |
| const opt = document.createElement("option"); | |
| opt.value = t.id; | |
| opt.textContent = t.name; | |
| el.template.appendChild(opt); | |
| } | |
| } | |
| /** Load a template's field values into the editable prompt fields. */ | |
| function applyTemplate(templateId) { | |
| el.system.value = getTemplateValue(PURPOSE, templateId, "system"); | |
| el.user.value = getTemplateValue(PURPOSE, templateId, "user"); | |
| } | |
| /** Picking a template fills the fields and is remembered for next time. */ | |
| function onTemplateChange() { | |
| applyTemplate(el.template.value); | |
| setSelectedTemplate(PURPOSE, el.template.value); | |
| } | |
| function toggleImage() { | |
| imageShown = !imageShown; | |
| syncImageToggle(); | |
| } | |
| /** Reflect `imageShown` into the preview; keeps pixels out of the DOM when hidden. */ | |
| function syncImageToggle() { | |
| el.toggleImgBtn.textContent = imageShown ? "Hide image" : "Show image"; | |
| if (imageShown && currentDataUrl) { | |
| el.img.src = currentDataUrl; | |
| el.img.hidden = false; | |
| } else { | |
| el.img.removeAttribute("src"); | |
| el.img.hidden = true; | |
| } | |
| } | |
| function close() { | |
| abortInflight(); | |
| imageShown = false; | |
| el.img.removeAttribute("src"); // drop the pixels from the DOM | |
| el.root.hidden = true; | |
| } | |
| /** First-turn messages: system + the user prompt with the image attached. */ | |
| function buildFreshMessages() { | |
| return [ | |
| { role: "system", content: el.system.value }, | |
| { | |
| role: "user", | |
| content: [ | |
| { type: "text", text: el.user.value }, | |
| { type: "image_url", image_url: { url: currentDataUrl } }, | |
| ], | |
| }, | |
| ]; | |
| } | |
| /** Send a message list; sets the result and returns the assistant text (null on error). */ | |
| async function send(messages) { | |
| abortInflight(); | |
| const ctrl = new AbortController(); | |
| inflight = ctrl; | |
| const timer = setTimeout(() => ctrl.abort(), GEN_TIMEOUT_MS); | |
| setBusy(true); | |
| setStatus("Generating…", ""); | |
| try { | |
| const text = await chat(messages, { | |
| signal: ctrl.signal, | |
| model: getTemplateModel(PURPOSE, el.template.value), | |
| }); | |
| el.result.value = text.trim(); | |
| setStatus( | |
| el.result.value ? "Done." : "The model returned an empty response.", | |
| el.result.value ? "ok" : "warn" | |
| ); | |
| return el.result.value; | |
| } catch (err) { | |
| setStatus(err.message, "error"); | |
| return null; | |
| } finally { | |
| clearTimeout(timer); | |
| inflight = null; | |
| setBusy(false); | |
| } | |
| } | |
| function afterGenerated() { | |
| el.runBtn.textContent = "Regenerate"; | |
| el.refineBtn.hidden = false; | |
| } | |
| /** Fresh generation from the image + fields (discards any prior conversation). */ | |
| async function run() { | |
| if (!currentDataUrl) { | |
| setStatus("Image not loaded yet.", "warn"); | |
| return; | |
| } | |
| const messages = buildFreshMessages(); | |
| const text = await send(messages); | |
| if (text != null) { | |
| history = [...messages, { role: "assistant", content: el.result.value }]; | |
| afterGenerated(); | |
| } | |
| } | |
| /** Refine: keep the conversation (incl. the image turn) and send the user prompt anew. */ | |
| async function refine() { | |
| if (!history) return run(); | |
| const base = history.slice(); | |
| if (base.length && base[base.length - 1].role === "assistant") { | |
| base[base.length - 1] = { role: "assistant", content: el.result.value }; | |
| } | |
| const instruction = el.user.value.trim() || "Refine the previous result."; | |
| const messages = [...base, { role: "user", content: instruction }]; | |
| const text = await send(messages); | |
| if (text != null) { | |
| history = [...messages, { role: "assistant", content: el.result.value }]; | |
| afterGenerated(); | |
| } | |
| } | |
| function accept() { | |
| const text = el.result.value; | |
| if (!text.trim()) return; | |
| deps.applyPrompt(text); | |
| toast("Prompt applied — review and save."); | |
| close(); | |
| } | |
| function abortInflight() { | |
| if (inflight) { | |
| inflight.abort(); | |
| inflight = null; | |
| } | |
| } | |
| /** Enable/disable controls for the generating vs idle states. */ | |
| function setBusy(busy) { | |
| el.stopBtn.hidden = !busy; | |
| el.refineBtn.disabled = busy; | |
| syncButtons(busy); | |
| } | |
| /** Generate needs a loaded image; Accept needs a (non-empty) result; neither while busy. */ | |
| function syncButtons(busy = false) { | |
| const hasResult = el.result.value.trim().length > 0; | |
| el.runBtn.disabled = busy || !currentDataUrl; | |
| el.acceptBtn.disabled = busy || !hasResult; | |
| } | |
| function setStatus(text, kind) { | |
| el.status.textContent = text; | |
| el.status.className = "llm-status" + (kind ? " " + kind : ""); | |
| } | |
| function fileToDataUrl(file) { | |
| return new Promise((resolve, reject) => { | |
| const r = new FileReader(); | |
| r.onload = () => resolve(r.result); | |
| r.onerror = () => reject(r.error || new Error("read failed")); | |
| r.readAsDataURL(file); | |
| }); | |
| } | |
| exports.initImageAi = initImageAi; | |
| exports.openImageAi = openImageAi; | |
| }); | |
| register("js/ask-ai.js", function (module, exports, require) { | |
| const { chat } = require("js/llm/llm-client.js"); | |
| const { isLlmOnline } = require("js/llm/llm-config.js"); | |
| const { PURPOSE_DEFS, listTemplates, getTemplateValue, getTemplateModel } = require("js/llm/llm-prompts.js"); | |
| const { toast } = require("js/toast.js"); | |
| /** | |
| * "Ask AI" — a general-purpose LLM playground, independent of any feature flow. | |
| * Pick any prompt template (across all purposes) to prefill, edit the system and | |
| * user prompts, optionally attach an image, send, and read/copy the response. | |
| * Nothing is applied anywhere; it's a scratchpad. | |
| */ | |
| const $ = (id) => document.getElementById(id); | |
| const GEN_TIMEOUT_MS = 120000; | |
| let el; | |
| let inflight = null; | |
| /** Conversation so far (null before the first send). Refine appends turns. */ | |
| let history = null; | |
| function initAskAi() { | |
| el = { | |
| root: $("askModal"), | |
| closeBtn: $("askCloseBtn"), | |
| template: $("askTemplate"), | |
| system: $("askSystem"), | |
| user: $("askUser"), | |
| imageInput: $("askImageInput"), | |
| clearImgBtn: $("askClearImgBtn"), | |
| runBtn: $("askRunBtn"), | |
| refineBtn: $("askRefineBtn"), | |
| stopBtn: $("askStopBtn"), | |
| status: $("askStatus"), | |
| result: $("askResult"), | |
| copyBtn: $("askCopyBtn"), | |
| }; | |
| el.closeBtn.addEventListener("click", close); | |
| el.root.querySelector(".modal-backdrop").addEventListener("click", close); | |
| document.addEventListener("keydown", (ev) => { | |
| if (!el.root.hidden && ev.key === "Escape") { | |
| ev.preventDefault(); | |
| close(); | |
| } | |
| }); | |
| el.template.addEventListener("change", onTemplateChange); | |
| el.imageInput.addEventListener("change", () => { | |
| el.clearImgBtn.hidden = el.imageInput.files.length === 0; | |
| }); | |
| el.clearImgBtn.addEventListener("click", () => { | |
| el.imageInput.value = ""; | |
| el.clearImgBtn.hidden = true; | |
| }); | |
| el.runBtn.addEventListener("click", run); | |
| el.refineBtn.addEventListener("click", refine); | |
| el.stopBtn.addEventListener("click", abortInflight); | |
| el.result.addEventListener("input", syncCopy); | |
| el.copyBtn.addEventListener("click", copy); | |
| } | |
| function openAskAi() { | |
| populateTemplates(); | |
| history = null; // fresh session | |
| el.refineBtn.hidden = true; // appears after the first send | |
| setBusy(false); | |
| setStatus("", ""); | |
| syncCopy(); | |
| el.root.hidden = false; | |
| el.user.focus(); | |
| } | |
| function close() { | |
| abortInflight(); | |
| el.root.hidden = true; | |
| } | |
| /** Build the template picker: a Custom entry, then all templates by purpose. */ | |
| function populateTemplates() { | |
| const prev = el.template.value; | |
| el.template.textContent = ""; | |
| const custom = document.createElement("option"); | |
| custom.value = ""; | |
| custom.textContent = "— Custom (no template) —"; | |
| el.template.appendChild(custom); | |
| for (const p of PURPOSE_DEFS) { | |
| const og = document.createElement("optgroup"); | |
| og.label = `${p.area} › ${p.label}`; | |
| for (const t of listTemplates(p.id)) { | |
| const o = document.createElement("option"); | |
| o.value = `${p.id}::${t.id}`; | |
| o.textContent = t.name; | |
| og.appendChild(o); | |
| } | |
| el.template.appendChild(og); | |
| } | |
| // Keep the previous selection if it still exists. | |
| el.template.value = [...el.template.options].some((o) => o.value === prev) ? prev : ""; | |
| } | |
| /** Selecting a template prefills system + user; Custom leaves them as-is. */ | |
| function onTemplateChange() { | |
| const [pid, tid] = el.template.value.split("::"); | |
| if (!pid) return; | |
| el.system.value = getTemplateValue(pid, tid, "system"); | |
| el.user.value = getTemplateValue(pid, tid, "user"); | |
| } | |
| /** First-turn messages: optional system + the user prompt (with image if attached). */ | |
| async function buildFreshMessages() { | |
| const messages = []; | |
| if (el.system.value.trim()) messages.push({ role: "system", content: el.system.value }); | |
| const file = el.imageInput.files[0]; | |
| if (file) { | |
| const dataUrl = await fileToDataUrl(file); | |
| messages.push({ | |
| role: "user", | |
| content: [ | |
| { type: "text", text: el.user.value }, | |
| { type: "image_url", image_url: { url: dataUrl } }, | |
| ], | |
| }); | |
| } else { | |
| messages.push({ role: "user", content: el.user.value }); | |
| } | |
| return messages; | |
| } | |
| /** Send a message list; sets the result and returns the assistant text (null on error). */ | |
| async function send(messages) { | |
| abortInflight(); | |
| const ctrl = new AbortController(); | |
| inflight = ctrl; | |
| const timer = setTimeout(() => ctrl.abort(), GEN_TIMEOUT_MS); | |
| setBusy(true); | |
| setStatus("Sending…", ""); | |
| try { | |
| const [pid, tid] = el.template.value.split("::"); | |
| const model = pid ? getTemplateModel(pid, tid) : ""; | |
| const text = await chat(messages, { signal: ctrl.signal, model }); | |
| el.result.value = text.trim(); | |
| setStatus(el.result.value ? "Done." : "Empty response.", el.result.value ? "ok" : "warn"); | |
| return el.result.value; | |
| } catch (err) { | |
| setStatus(err.message, "error"); | |
| return null; | |
| } finally { | |
| clearTimeout(timer); | |
| inflight = null; | |
| setBusy(false); | |
| syncCopy(); | |
| } | |
| } | |
| /** Fresh send: rebuild from the fields, discarding any prior conversation. */ | |
| async function run() { | |
| let messages; | |
| try { | |
| messages = await buildFreshMessages(); | |
| } catch (err) { | |
| setStatus("Could not read image: " + err.message, "error"); | |
| return; | |
| } | |
| const text = await send(messages); | |
| if (text != null) { | |
| history = [...messages, { role: "assistant", content: el.result.value }]; | |
| el.refineBtn.hidden = false; | |
| } | |
| } | |
| /** Refine: keep the conversation and send the current user prompt as a new turn. */ | |
| async function refine() { | |
| if (!history) return run(); | |
| const base = history.slice(); | |
| if (base.length && base[base.length - 1].role === "assistant") { | |
| base[base.length - 1] = { role: "assistant", content: el.result.value }; | |
| } | |
| const instruction = el.user.value.trim() || "Refine the previous response."; | |
| const messages = [...base, { role: "user", content: instruction }]; | |
| const text = await send(messages); | |
| if (text != null) { | |
| history = [...messages, { role: "assistant", content: el.result.value }]; | |
| el.refineBtn.hidden = false; | |
| } | |
| } | |
| async function copy() { | |
| try { | |
| await navigator.clipboard.writeText(el.result.value); | |
| toast("Response copied."); | |
| } catch { | |
| toast("Clipboard blocked by the browser.", "warn"); | |
| } | |
| } | |
| function abortInflight() { | |
| if (inflight) { | |
| inflight.abort(); | |
| inflight = null; | |
| } | |
| } | |
| function setBusy(busy) { | |
| el.runBtn.disabled = busy; | |
| el.refineBtn.disabled = busy; | |
| el.stopBtn.hidden = !busy; | |
| } | |
| function syncCopy() { | |
| el.copyBtn.disabled = el.result.value.trim().length === 0; | |
| } | |
| function setStatus(text, kind) { | |
| el.status.textContent = text; | |
| el.status.className = "llm-status" + (kind ? " " + kind : ""); | |
| } | |
| function fileToDataUrl(file) { | |
| return new Promise((resolve, reject) => { | |
| const r = new FileReader(); | |
| r.onload = () => resolve(r.result); | |
| r.onerror = () => reject(r.error || new Error("read failed")); | |
| r.readAsDataURL(file); | |
| }); | |
| } | |
| exports.initAskAi = initAskAi; | |
| exports.openAskAi = openAskAi; | |
| }); | |
| require("js/app.js"); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |