Files
LittleWhiteBox/modules/story-summary/story-summary.html
2026-01-17 16:34:39 +08:00

1724 lines
81 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>剧情总结 · Story Summary</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box
}
:root {
--bg: #fafafa;
--bg2: #fff;
--bg3: #f5f5f5;
--txt: #1a1a1a;
--txt2: #444;
--txt3: #666;
--bdr: #dcdcdc;
--bdr2: #e8e8e8;
--acc: #1a1a1a;
--hl: #d87a7a;
--hl-soft: rgba(184, 90, 90, .1)
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--txt);
line-height: 1.6;
min-height: 100vh;
-webkit-overflow-scrolling: touch
}
.container {
display: flex;
flex-direction: column;
min-height: 100vh;
padding: 24px 40px;
max-width: 1800px;
margin: 0 auto
}
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 24px;
border-bottom: 1px solid var(--bdr);
margin-bottom: 24px
}
h1 {
font-size: 2rem;
font-weight: 300;
letter-spacing: -.02em;
margin-bottom: 4px
}
h1 span {
font-weight: 600
}
.subtitle {
font-size: .875rem;
color: var(--txt3);
letter-spacing: .05em;
text-transform: uppercase
}
.stats {
display: flex;
gap: 48px;
text-align: right
}
.stat {
display: flex;
flex-direction: column
}
.stat-val {
font-size: 2.5rem;
font-weight: 200;
line-height: 1;
letter-spacing: -.03em
}
.stat-val .hl {
color: var(--hl)
}
.stat-lbl {
font-size: .75rem;
color: var(--txt3);
text-transform: uppercase;
letter-spacing: .1em;
margin-top: 4px
}
.controls {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 0;
margin-bottom: 20px
}
.chk-label {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg3);
border: 1px solid var(--bdr);
font-size: .8125rem;
color: var(--txt2);
cursor: pointer;
transition: all .2s
}
.chk-label:hover {
border-color: var(--acc)
}
.chk-label input {
width: 16px;
height: 16px;
accent-color: var(--hl);
cursor: pointer
}
.chk-label strong {
color: var(--hl)
}
.btn {
padding: 12px 28px;
background: var(--bg2);
color: var(--txt);
border: 1px solid var(--bdr);
font-size: .875rem;
font-weight: 500;
cursor: pointer;
transition: all .2s
}
.btn:hover {
border-color: var(--acc);
background: var(--bg3)
}
.btn-p {
background: var(--acc);
color: #fff;
border-color: var(--acc)
}
.btn-p:hover {
background: #555
}
.btn-p:disabled {
background: #999;
border-color: #999;
cursor: not-allowed;
opacity: .7
}
.btn-icon {
padding: 10px 16px;
display: flex;
align-items: center;
gap: 6px
}
.btn-icon svg {
width: 16px;
height: 16px
}
.btn-sm {
padding: 8px 16px;
font-size: .8125rem
}
.btn-del {
background: transparent;
color: var(--hl);
border-color: var(--hl)
}
.btn-del:hover {
background: var(--hl-soft)
}
.spacer {
flex: 1
}
main {
display: grid;
grid-template-columns: 1fr 480px;
gap: 24px;
flex: 1;
min-height: 0
}
.sec-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px
}
.sec-title {
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .15em;
color: var(--txt2)
}
.sec-btn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--bdr);
font-size: .6875rem;
color: var(--txt3);
cursor: pointer;
transition: all .2s;
text-transform: uppercase;
letter-spacing: .05em
}
.sec-btn:hover {
border-color: var(--acc);
color: var(--txt);
background: var(--bg3)
}
.sec-actions {
display: flex;
gap: 8px;
align-items: center
}
.sec-icon {
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: center
}
.left {
display: flex;
flex-direction: column;
gap: 24px;
min-height: 0
}
.card {
background: var(--bg2);
border: 1px solid var(--bdr);
padding: 24px
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px
}
.tag {
padding: 8px 20px;
background: var(--bg3);
border: 1px solid var(--bdr2);
font-size: .875rem;
color: var(--txt2);
transition: all .2s;
cursor: default
}
.tag.p {
background: var(--acc);
color: #fff;
border-color: var(--acc);
font-weight: 500
}
.tag.s {
background: var(--hl-soft);
border-color: rgba(255, 68, 68, .2);
color: var(--hl)
}
.tag:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, .08)
}
.timeline {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
max-height: 750px
}
.tl-list {
flex: 1;
overflow-y: auto;
padding-right: 8px;
min-height: 0
}
.tl-list::-webkit-scrollbar,
.scroll::-webkit-scrollbar {
width: 4px
}
.tl-list::-webkit-scrollbar-thumb,
.scroll::-webkit-scrollbar-thumb {
background: var(--bdr)
}
.tl-item {
position: relative;
padding-left: 32px;
padding-bottom: 32px;
border-left: 1px solid var(--bdr);
margin-left: 8px
}
.tl-item:last-child {
border-left-color: transparent;
padding-bottom: 0
}
.tl-dot {
position: absolute;
left: -5px;
top: 0;
width: 9px;
height: 9px;
background: var(--bg2);
border: 2px solid var(--txt3);
border-radius: 50%;
transition: all .2s
}
.tl-item:hover .tl-dot {
border-color: var(--hl);
background: var(--hl);
transform: scale(1.3)
}
.tl-item.crit .tl-dot {
border-color: var(--hl);
background: var(--hl)
}
.tl-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px
}
.tl-title {
font-size: 1rem;
font-weight: 500
}
.tl-time {
font-size: .75rem;
color: var(--txt3);
font-variant-numeric: tabular-nums
}
.tl-brief {
font-size: .875rem;
color: var(--txt2);
line-height: 1.7;
margin-bottom: 12px
}
.tl-meta {
display: flex;
gap: 16px;
font-size: .75rem;
color: var(--txt3)
}
.tl-meta .imp {
color: var(--hl);
font-weight: 500
}
.right {
display: flex;
flex-direction: column;
gap: 24px;
min-height: 0
}
.relations {
flex: 1;
display: flex;
flex-direction: column;
min-height: 430px
}
#relation-chart,
#relation-chart-fullscreen {
width: 100%;
height: 100%;
flex: 1;
min-height: 200px;
touch-action: none
}
.profile {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
max-height: 480px
}
.profile-content {
flex: 1;
overflow-y: auto;
padding-right: 8px
}
.custom-select {
position: relative;
min-width: 140px;
font-size: .8125rem
}
.sel-trigger {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: var(--bg3);
border: 1px solid var(--bdr);
border-radius: 6px;
cursor: pointer;
transition: all .2s;
user-select: none
}
.sel-trigger:hover {
border-color: var(--acc);
background: var(--bg2)
}
.sel-trigger::after {
content: '';
width: 16px;
height: 16px;
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238d8d8d' stroke-width='2'%3e%3cpath d='M6 9l6 6 6-6'/%3e%3c/svg%3e") center/16px no-repeat;
transition: transform .2s
}
.custom-select.open .sel-trigger::after {
transform: rotate(180deg)
}
.sel-opts {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg2);
border: 1px solid var(--bdr);
border-radius: 6px;
box-shadow: 0 4px 20px rgba(0, 0, 0, .15);
z-index: 100;
display: none;
max-height: 240px;
overflow-y: auto;
padding: 4px
}
.custom-select.open .sel-opts {
display: block;
animation: fadeIn .2s
}
.sel-opt {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background .1s
}
.sel-opt:hover {
background: var(--bg3)
}
.sel-opt.sel {
background: var(--hl-soft);
color: var(--hl);
font-weight: 600
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px)
}
to {
opacity: 1;
transform: translateY(0)
}
}
.prof-arc {
padding: 16px;
margin-bottom: 24px
}
.prof-name {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 4px
}
.prof-traj {
font-size: .8125rem;
color: var(--txt3);
line-height: 1.5
}
.prof-prog-wrap {
margin-bottom: 16px
}
.prof-prog-lbl {
display: flex;
justify-content: space-between;
font-size: .75rem;
color: var(--txt3);
margin-bottom: 6px
}
.prof-prog {
height: 4px;
background: var(--bdr);
border-radius: 2px;
overflow: hidden
}
.prof-prog-inner {
height: 100%;
background: linear-gradient(90deg, var(--hl), #d85858);
border-radius: 2px;
transition: width .6s
}
.prof-moments {
background: var(--bg2);
border-left: 3px solid var(--hl);
padding: 12px 16px
}
.prof-moments-title {
font-size: .6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--txt3);
margin-bottom: 8px
}
.prof-moment {
position: relative;
padding-left: 16px;
margin-bottom: 6px;
font-size: .8125rem;
color: var(--txt2);
line-height: 1.5
}
.prof-moment::before {
content: '';
position: absolute;
left: 0;
top: 7px;
width: 6px;
height: 6px;
background: var(--hl);
border-radius: 50%
}
.prof-moment:last-child {
margin-bottom: 0
}
.prof-rels {
display: flex;
flex-direction: column
}
.rels-group {
border-bottom: 1px solid var(--bdr2);
padding: 16px 0
}
.rels-group:last-child {
border-bottom: none;
padding-bottom: 0
}
.rels-group:first-child {
padding-top: 0
}
.rels-group-title {
font-size: .75rem;
font-weight: 600;
color: var(--txt3);
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px
}
.rel-item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 2px;
}
.rel-item:hover {
background: var(--bg3)
}
.rel-target {
font-size: .9rem;
color: var(--txt2);
white-space: nowrap;
min-width: 60px
}
.rel-label {
font-size: .7rem;
line-height: 1.5;
flex: 1
}
.rel-trend {
font-size: .6875rem;
padding: 2px 8px;
border-radius: 10px;
white-space: nowrap
}
.trend-broken {
background: rgba(68, 68, 68, .15);
color: #444
}
.trend-hate {
background: rgba(139, 0, 0, .15);
color: #8b0000
}
.trend-dislike {
background: rgba(205, 92, 92, .15);
color: #cd5c5c
}
.trend-stranger {
background: rgba(136, 136, 136, .15);
color: #888
}
.trend-click {
background: rgba(102, 205, 170, .15);
color: #4a9a7e
}
.trend-close {
background: rgba(235, 106, 106, .15);
color: var(--hl)
}
.trend-merge {
background: rgba(199, 21, 133, .2);
color: #c71585
}
.empty {
text-align: center;
padding: 40px;
color: var(--txt3);
font-size: .875rem
}
.modal {
position: fixed;
inset: 0;
z-index: 10000;
display: none;
align-items: center;
justify-content: center
}
.modal.active {
display: flex
}
.modal-bg {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, .5);
backdrop-filter: blur(4px)
}
.modal-box {
position: relative;
width: 100%;
max-width: 720px;
max-height: 90vh;
background: var(--bg2);
border: 1px solid var(--bdr);
overflow: hidden;
display: flex;
flex-direction: column
}
.modal-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--bdr)
}
.modal-head h2 {
font-size: 1rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .1em
}
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--bdr);
cursor: pointer;
transition: all .2s
}
.modal-close:hover {
background: var(--bg3);
border-color: var(--acc)
}
.modal-close svg {
width: 14px;
height: 14px
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 24px
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--bdr)
}
.editor-ta {
width: 100%;
min-height: 300px;
padding: 16px;
background: var(--bg3);
border: 1px solid var(--bdr);
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: .8125rem;
line-height: 1.6;
color: var(--txt);
resize: vertical;
outline: none
}
.editor-ta:focus {
border-color: var(--acc)
}
.editor-hint {
font-size: .75rem;
color: var(--txt3);
margin-bottom: 12px;
line-height: 1.5
}
.editor-err {
padding: 12px;
background: var(--hl-soft);
border: 1px solid rgba(255, 68, 68, .3);
color: var(--hl);
font-size: .8125rem;
margin-top: 12px;
display: none
}
.editor-err.visible {
display: block
}
.struct-item {
border: 1px solid var(--bdr);
background: var(--bg3);
padding: 12px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 6px
}
.struct-row {
display: flex;
gap: 8px;
flex-wrap: wrap
}
.struct-row input,
.struct-row select,
.struct-row textarea {
flex: 1;
min-width: 0;
padding: 8px 10px;
background: var(--bg2);
border: 1px solid var(--bdr);
font-size: .8125rem;
color: var(--txt);
outline: none;
transition: border-color .2s
}
.struct-row input:focus,
.struct-row select:focus,
.struct-row textarea:focus {
border-color: var(--acc)
}
.struct-row textarea {
resize: vertical;
font-family: inherit;
min-height: 60px
}
.struct-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px
}
.struct-actions span {
font-size: .75rem;
color: var(--txt3)
}
.settings-section {
margin-bottom: 32px
}
.settings-section:last-child {
margin-bottom: 0
}
.settings-section-title {
font-size: .6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .15em;
color: var(--txt3);
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid var(--bdr2)
}
.settings-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap
}
.settings-row:last-child {
margin-bottom: 0
}
.settings-field {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 200px
}
.settings-field.full {
flex: 100%
}
.settings-field label {
font-size: .75rem;
color: var(--txt3);
text-transform: uppercase;
letter-spacing: .05em
}
.settings-field input,
.settings-field select {
padding: 10px 14px;
background: var(--bg3);
border: 1px solid var(--bdr);
font-size: .875rem;
color: var(--txt);
outline: none;
transition: border-color .2s
}
.settings-field input:focus,
.settings-field select:focus {
border-color: var(--acc)
}
.settings-field input[type="password"] {
letter-spacing: .15em
}
.settings-field-inline {
display: flex;
align-items: center;
gap: 8px
}
.settings-field-inline input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--acc)
}
.settings-field-inline label {
font-size: .8125rem;
color: var(--txt2);
text-transform: none;
letter-spacing: 0
}
.settings-hint {
font-size: .75rem;
color: var(--txt3);
margin-top: 4px
}
.settings-btn-row {
display: flex;
gap: 12px;
margin-top: 8px
}
.hidden {
display: none !important
}
.fullscreen .modal-box {
width: calc(100vw - 48px);
max-width: none;
height: calc(100vh - 48px);
max-height: none
}
.fullscreen .modal-body {
flex: 1;
padding: 0;
overflow: hidden
}
#keep-visible-count {
width: 32px;
padding: 2px 4px;
margin: 0 2px;
background: var(--bg2);
border: 1px solid var(--bdr);
font-size: inherit;
font-weight: bold;
color: var(--hl);
text-align: center;
border-radius: 3px
}
#keep-visible-count:focus {
border-color: var(--acc);
outline: none
}
.stat-warning {
font-size: .625rem;
color: #ff9800;
margin-top: 4px
}
@media(max-width:1200px) {
.container {
padding: 16px 24px
}
main {
grid-template-columns: 1fr
}
.right {
flex-direction: row
}
.relations,
.profile {
flex: 1;
min-height: 300px
}
}
@media(max-width:768px) {
.container {
height: auto;
min-height: 100vh;
padding: 16px
}
header {
flex-direction: column;
gap: 16px;
padding-bottom: 16px;
margin-bottom: 16px
}
h1 {
font-size: 1.5rem
}
.stats {
width: 100%;
justify-content: space-between;
gap: 16px;
text-align: center
}
.stat-val {
font-size: 1.75rem
}
.stat-lbl {
font-size: .625rem
}
.controls {
flex-wrap: wrap;
gap: 10px;
padding: 10px 0;
margin-bottom: 16px
}
.spacer {
display: none
}
.btn {
padding: 10px 20px;
font-size: .8125rem
}
.btn-icon {
padding: 10px 14px
}
main,
.left,
.right {
gap: 16px
}
.right {
flex-direction: column
}
.timeline {
max-height: 400px
}
.relations,
.profile {
min-height: 280px;
max-height: 400px;
padding: 16px
}
.card {
padding: 16px
}
.keywords {
gap: 8px;
margin-top: 12px
}
.tag {
padding: 6px 14px;
font-size: .8125rem
}
.tl-item {
padding-left: 24px;
padding-bottom: 24px
}
.tl-title {
font-size: .9375rem
}
.tl-brief {
font-size: .8125rem;
line-height: 1.6
}
.modal-box {
max-width: 100%;
max-height: 100%;
height: 100%;
border: none
}
.modal-head,
.modal-body,
.modal-foot {
padding: 16px
}
.settings-row {
flex-direction: column;
gap: 12px
}
.settings-field {
min-width: 100%
}
.settings-field input,
.settings-field select {
padding: 12px 14px;
font-size: 1rem
}
.fullscreen .modal-box {
width: 100%;
height: 100%;
border-radius: 0
}
}
@media(max-width:480px) {
.container {
padding: 12px
}
header {
padding-bottom: 12px;
margin-bottom: 12px
}
h1 {
font-size: 1.25rem
}
.subtitle {
font-size: .6875rem
}
.stats {
gap: 8px
}
.stat {
flex: 1
}
.stat-val {
font-size: 1.5rem
}
.controls {
gap: 8px;
padding: 8px 0;
margin-bottom: 12px
}
.btn {
flex: 1;
padding: 10px 12px;
font-size: .75rem;
text-align: center;
justify-content: center
}
.btn-icon {
padding: 10px 12px
}
.btn-icon svg {
width: 14px;
height: 14px
}
main,
.left,
.right {
gap: 12px
}
.card {
padding: 12px
}
.sec-title {
font-size: .6875rem
}
.sec-btn {
font-size: .625rem;
padding: 3px 8px
}
.relations {
min-height: 240px
}
#relation-chart {
min-height: 180px
}
.keywords {
gap: 6px;
margin-top: 10px
}
.tag {
padding: 5px 10px;
font-size: .75rem
}
.tl-item {
padding-left: 20px;
padding-bottom: 20px;
margin-left: 6px
}
.tl-dot {
width: 7px;
height: 7px;
left: -4px
}
.tl-head {
flex-direction: column;
align-items: flex-start;
gap: 2px
}
.tl-title {
font-size: .875rem
}
.tl-time {
font-size: .6875rem
}
.tl-brief {
font-size: .8rem;
margin-bottom: 8px
}
.tl-meta {
flex-direction: column;
gap: 4px;
font-size: .6875rem
}
.modal-head h2 {
font-size: .875rem
}
.settings-section-title {
font-size: .625rem
}
.settings-field label {
font-size: .6875rem
}
.settings-field-inline label {
font-size: .75rem
}
.settings-hint {
font-size: .6875rem
}
.btn-sm {
padding: 10px 14px;
font-size: .75rem;
width: 100%
}
.editor-ta {
min-height: 200px;
font-size: .75rem
}
}
@media(hover:none)and (pointer:coarse) {
.btn {
min-height: 44px
}
.tag {
min-height: 36px;
display: flex;
align-items: center
}
.tag:hover {
transform: none
}
.tl-item:hover .tl-dot {
transform: none
}
.modal-close {
width: 44px;
height: 44px
}
.settings-field input,
.settings-field select {
min-height: 44px
}
.settings-field-inline input[type="checkbox"] {
width: 22px;
height: 22px
}
.sec-btn {
min-height: 32px;
padding: 6px 12px
}
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>剧情<span>总结</span></h1>
<div class="subtitle">Story Summary · Timeline · Character Arcs</div>
</div>
<div class="stats">
<div class="stat">
<div class="stat-val" id="stat-events">0</div>
<div class="stat-lbl">已记录事件</div>
</div>
<div class="stat">
<div class="stat-val" id="stat-summarized">0</div>
<div class="stat-lbl">已总结楼层</div>
</div>
<div class="stat">
<div class="stat-val"><span class="hl" id="stat-pending">0</span></div>
<div class="stat-lbl">待总结</div>
<div class="stat-warning hidden" id="pending-warning">再删1条将回滚</div>
</div>
</div>
</header>
<div class="controls">
<label class="chk-label">
<input type="checkbox" id="hide-summarized">
<span>聊天时隐藏已总结 · <strong id="summarized-count">0</strong> 楼(保留<input type="number"
id="keep-visible-count" min="0" max="50" value="3">楼)</span>
</label>
<span class="spacer"></span>
<button class="btn btn-icon" id="btn-settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>设置</button>
<button class="btn" id="btn-clear">清空</button>
<button class="btn btn-p" id="btn-generate">总结</button>
</div>
<main>
<div class="left">
<section class="card">
<div class="sec-head">
<div class="sec-title">核心关键词</div><button class="sec-btn" data-section="keywords">编辑</button>
</div>
<div class="keywords" id="keywords-cloud"></div>
</section>
<section class="card timeline">
<div class="sec-head">
<div class="sec-title">剧情时间线</div><button class="sec-btn" data-section="events">编辑</button>
</div>
<div class="tl-list scroll" id="timeline-list"></div>
</section>
</div>
<div class="right">
<section class="card relations">
<div class="sec-head">
<div class="sec-title">人物关系</div>
<div class="sec-actions">
<button class="sec-btn sec-icon" id="btn-fullscreen-relations" title="全屏查看"><svg
viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor"
stroke-width="2">
<path
d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg></button>
<button class="sec-btn" data-section="characters">编辑</button>
</div>
</div>
<div id="relation-chart"></div>
</section>
<section class="card profile">
<div class="sec-head">
<div class="sec-title">人物档案</div>
<div class="sec-actions">
<div class="custom-select" id="char-sel">
<div class="sel-trigger" id="char-sel-trigger"><span id="sel-char-text">选择角色</span>
</div>
<div class="sel-opts" id="char-sel-opts">
<div class="sel-opt" data-value="">暂无角色</div>
</div>
</div>
<button class="sec-btn" data-section="arcs">编辑</button>
</div>
</div>
<div class="profile-content scroll" id="profile-content"></div>
</section>
</div>
</main>
</div>
<div class="modal" id="editor-modal">
<div class="modal-bg" id="editor-backdrop"></div>
<div class="modal-box">
<div class="modal-head">
<h2 id="editor-title">编辑</h2><button class="modal-close" id="editor-close"><svg viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg></button>
</div>
<div class="modal-body">
<div class="editor-hint" id="editor-hint"></div>
<div id="editor-struct" class="hidden"></div>
<textarea class="editor-ta" id="editor-ta"></textarea>
<div class="editor-err" id="editor-err"></div>
</div>
<div class="modal-foot"><button class="btn" id="editor-cancel">取消</button><button class="btn btn-p"
id="editor-save">保存</button></div>
</div>
</div>
<div class="modal" id="settings-modal">
<div class="modal-bg" id="settings-backdrop"></div>
<div class="modal-box">
<div class="modal-head">
<h2>设置</h2><button class="modal-close" id="settings-close"><svg viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg></button>
</div>
<div class="modal-body">
<div class="settings-section">
<div class="settings-section-title">API 配置</div>
<div class="settings-row">
<div class="settings-field"><label>渠道</label><select id="api-provider">
<option value="st">酒馆主 API沿用当前</option>
<option value="openai">OpenAI 兼容</option>
<option value="google">Google (Gemini)</option>
<option value="claude">Claude (Anthropic)</option>
<option value="deepseek">DeepSeek</option>
<option value="cohere">Cohere</option>
<option value="custom">自定义</option>
</select></div>
</div>
<div class="settings-row hidden" id="api-url-row">
<div class="settings-field full"><label>API URL</label><input type="text" id="api-url"
placeholder="https://api.openai.com 或代理地址">
<div class="settings-hint">不同渠道默认端点OpenAI 用 /v1Gemini 用 /v1betaClaude 用 /v1</div>
</div>
</div>
<div class="settings-row hidden" id="api-key-row">
<div class="settings-field full"><label>API KEY</label><input type="password" id="api-key"
placeholder="仅保存在本地,不会上传"></div>
</div>
<div class="settings-row hidden" id="api-model-manual-row">
<div class="settings-field full"><label>模型</label><input type="text" id="api-model-text"
placeholder="如 gemini-1.5-pro、claude-3-haiku"></div>
</div>
<div class="settings-row hidden" id="api-model-select-row">
<div class="settings-field full"><label>可用模型</label><select id="api-model-select">
<option value="">请先拉取模型列表</option>
</select></div>
</div>
<div class="settings-btn-row hidden" id="api-connect-row"><button class="btn btn-sm btn-p"
id="btn-connect">连接 / 拉取模型列表</button></div>
</div>
<div class="settings-section">
<div class="settings-section-title">生成参数</div>
<div class="settings-row">
<div class="settings-field"><label>Temperature</label><input type="number" id="gen-temp"
step="0.01" min="0" max="2" placeholder="未设置"></div>
<div class="settings-field"><label>Top P</label><input type="number" id="gen-top-p" step="0.01"
min="0" max="1" placeholder="未设置"></div>
<div class="settings-field"><label>Top K</label><input type="number" id="gen-top-k" step="1"
min="1" placeholder="未设置"></div>
</div>
<div class="settings-row">
<div class="settings-field"><label>存在惩罚</label><input type="number" id="gen-presence"
step="0.01" min="-2" max="2" placeholder="未设置"></div>
<div class="settings-field"><label>频率惩罚</label><input type="number" id="gen-frequency"
step="0.01" min="-2" max="2" placeholder="未设置"></div>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">总结设置</div>
<div class="settings-row">
<div class="settings-field"><label>自动总结间隔(楼)</label><input type="number" id="trigger-interval"
min="5" step="5" value="20"></div>
<div class="settings-field"><label>触发时机</label><select id="trigger-timing">
<option value="after_ai">AI 回复后</option>
<option value="before_user">用户发送前</option>
<option value="manual">仅手动</option>
</select></div>
<div class="settings-field"><label>单次最大总结(楼)</label><select id="trigger-max-per-run">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="150">150</option>
<option value="200">200</option>
</select></div>
</div>
<div class="settings-row">
<div class="settings-field-inline"><input type="checkbox" id="trigger-enabled"><label
for="trigger-enabled">启用自动总结</label></div>
<div class="settings-field-inline"><input type="checkbox" id="trigger-stream" checked><label
for="trigger-stream">启用流式生成</label></div>
</div>
<div class="settings-hint" style="margin-top:8px">若 API 不支持非流式请求,请勾选"启用流式生成"</div>
</div>
</div>
<div class="modal-foot"><button class="btn" id="settings-cancel">取消</button><button class="btn btn-p"
id="settings-save">保存</button></div>
</div>
</div>
<div class="modal fullscreen" id="rel-fs-modal">
<div class="modal-bg" id="rel-fs-backdrop"></div>
<div class="modal-box">
<div class="modal-head">
<h2>人物关系图</h2><button class="modal-close" id="rel-fs-close"><svg viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg></button>
</div>
<div class="modal-body">
<div id="relation-chart-fullscreen"></div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
const $ = id => document.getElementById(id), $$ = sel => document.querySelectorAll(sel);
const escapeHtml = (v) => String(v ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" })[c]);
const h = (v) => escapeHtml(v);
const config = { api: { provider: 'st', url: '', key: '', model: '', modelCache: [] }, gen: { temperature: null, top_p: null, top_k: null, presence_penalty: null, frequency_penalty: null }, trigger: { enabled: false, interval: 20, timing: 'after_ai', useStream: true, maxPerRun: 100 } };
let summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [] }, localGenerating = false, relationChart = null, relationChartFullscreen = null, currentEditSection = null, currentCharacterId = null, allNodes = [], allLinks = [], activeRelationTooltip = null;
const providerDefaults = { st: { url: '', needKey: false, canFetch: false, needManualModel: false }, openai: { url: 'https://api.openai.com', needKey: true, canFetch: true, needManualModel: false }, google: { url: 'https://generativelanguage.googleapis.com', needKey: true, canFetch: false, needManualModel: true }, claude: { url: 'https://api.anthropic.com', needKey: true, canFetch: false, needManualModel: true }, deepseek: { url: 'https://api.deepseek.com', needKey: true, canFetch: true, needManualModel: false }, cohere: { url: 'https://api.cohere.ai', needKey: true, canFetch: false, needManualModel: true }, custom: { url: '', needKey: true, canFetch: true, needManualModel: false } };
const sectionMeta = { keywords: { title: '编辑关键词', hint: '每行一个关键词,格式:关键词|权重(核心/重要/一般)' }, events: { title: '编辑事件时间线', hint: '编辑时,每个事件要素都应完整' }, characters: { title: '编辑人物关系', hint: '编辑时,每个要素都应完整' }, arcs: { title: '编辑角色弧光', hint: '编辑时,每个要素都应完整' } };
const trendColors = { '破裂': '#444444', '厌恶': '#8b0000', '反感': '#cd5c5c', '陌生': '#888888', '投缘': '#4a9a7e', '亲密': '#d87a7a', '交融': '#c71585' };
const trendClass = { '破裂': 'trend-broken', '厌恶': 'trend-hate', '反感': 'trend-dislike', '陌生': 'trend-stranger', '投缘': 'trend-click', '亲密': 'trend-close', '交融': 'trend-merge' };
const getCharName = c => typeof c === 'string' ? c : c.name;
const preserveAddedAt = (n, o) => { if (o?._addedAt != null) n._addedAt = o._addedAt; return n };
const PARENT_ORIGIN = (() => {
try { return new URL(document.referrer).origin; } catch { return window.location.origin; }
})();
const postMsg = (type, data = {}) => window.parent.postMessage({ source: 'LittleWhiteBox-StoryFrame', type, ...data }, PARENT_ORIGIN);
function loadConfig() { try { const s = localStorage.getItem('summary_panel_config'); if (s) { const p = JSON.parse(s); Object.assign(config.api, p.api || {}); Object.assign(config.gen, p.gen || {}); Object.assign(config.trigger, p.trigger || {}); if (config.trigger.timing === 'manual' && config.trigger.enabled) { config.trigger.enabled = false; saveConfig() } } } catch { } }
function applyConfig(cfg) { if (!cfg) return; Object.assign(config.api, cfg.api || {}); Object.assign(config.gen, cfg.gen || {}); Object.assign(config.trigger, cfg.trigger || {}); if (config.trigger.timing === 'manual' && config.trigger.enabled) { config.trigger.enabled = false; } localStorage.setItem('summary_panel_config', JSON.stringify(config)); }
function saveConfig() { try { localStorage.setItem('summary_panel_config', JSON.stringify(config)); postMsg('SAVE_PANEL_CONFIG', { config }); } catch { } }
function renderKeywords(kw) { summaryData.keywords = kw || []; const wc = { '核心': 'p', '重要': 's', high: 'p', medium: 's' }; $('keywords-cloud').innerHTML = kw.length ? kw.map(k => `<span class="tag ${wc[k.weight] || wc[k.level] || ''}">${h(k.text)}</span>`).join('') : '<div class="empty">暂无关键词</div>' }
function renderTimeline(ev) { summaryData.events = ev || []; const c = $('timeline-list'); if (!ev?.length) { c.innerHTML = '<div class="empty">暂无事件记录</div>'; return } c.innerHTML = ev.map(e => { const participants = (e.participants || e.characters || []).map(h).join('、'); return `<div class="tl-item${e.weight === '核心' || e.weight === '主线' ? ' crit' : ''}"><div class="tl-dot"></div><div class="tl-head"><div class="tl-title">${h(e.title || '')}</div><div class="tl-time">${h(e.timeLabel || '')}</div></div><div class="tl-brief">${h(e.summary || e.brief || '')}</div><div class="tl-meta"><span>人物:${participants || '—'}</span><span class="imp">${h(e.type || '')}${e.type && e.weight ? ' · ' : ''}${h(e.weight || '')}</span></div></div>` }).join('') }
function hideRelationTooltip() { if (activeRelationTooltip) { activeRelationTooltip.remove(); activeRelationTooltip = null } }
function showRelationTooltip(from, to, fromLabel, toLabel, fromTrend, toTrend, x, y, container) {
hideRelationTooltip(); const tip = document.createElement('div'); const mobile = innerWidth <= 768;
const fc = trendColors[fromTrend] || '#888', tc = trendColors[toTrend] || '#888';
const sf = h(from), st = h(to), sfl = h(fromLabel), stl = h(toLabel), sft = h(fromTrend), stt = h(toTrend);
tip.innerHTML = `<div style="line-height:1.8">${fromLabel ? `<div><small>${sf}${st}</small> <span style="color:${fc}">${sfl}</span> <span style="font-size:10px;color:${fc}">[${sft}]</span></div>` : ''}${toLabel ? `<div><small>${st}${sf}</small> <span style="color:${tc}">${stl}</span> <span style="font-size:10px;color:${tc}">[${stt}]</span></div>` : ''}</div>`;
tip.style.cssText = mobile ? `position:absolute;left:8px;bottom:8px;background:#fff;color:#333;padding:10px 14px;border:1px solid #ddd;border-radius:6px;font-size:12px;z-index:100;box-shadow:0 2px 12px rgba(0,0,0,.15);max-width:calc(100% - 16px)` : `position:absolute;left:${Math.max(80, Math.min(x, container.clientWidth - 80))}px;top:${Math.max(60, y)}px;transform:translate(-50%,-100%);background:#fff;color:#333;padding:10px 16px;border:1px solid #ddd;border-radius:6px;font-size:12px;z-index:1000;box-shadow:0 4px 12px rgba(0,0,0,.15);max-width:280px`;
container.style.position = 'relative'; container.appendChild(tip); activeRelationTooltip = tip;
}
function renderRelations(data) {
summaryData.characters = data || { main: [], relationships: [] }; const dom = $('relation-chart'); if (!relationChart) relationChart = echarts.init(dom);
const rels = data?.relationships || [], allNames = new Set((data?.main || []).map(getCharName));
rels.forEach(r => { if (r.from) allNames.add(r.from); if (r.to) allNames.add(r.to) });
const degrees = {}; rels.forEach(r => { degrees[r.from] = (degrees[r.from] || 0) + 1; degrees[r.to] = (degrees[r.to] || 0) + 1 });
const nodeColors = { main: '#d87a7a', sec: '#f1c3c3', ter: '#888888', qua: '#b8b8b8' };
const sortedDegs = Object.values(degrees).sort((a, b) => b - a);
const getPercentile = deg => { if (!sortedDegs.length || deg === 0) return 100; const rank = sortedDegs.filter(d => d > deg).length; return (rank / sortedDegs.length) * 100 };
allNodes = Array.from(allNames).map(name => { const deg = degrees[name] || 0, pct = getPercentile(deg); let col, fontWeight; if (pct < 30) { col = nodeColors.main; fontWeight = '600' } else if (pct < 60) { col = nodeColors.sec; fontWeight = '500' } else if (pct < 90) { col = nodeColors.ter; fontWeight = '400' } else { col = nodeColors.qua; fontWeight = '400' } return { id: name, name, symbol: 'circle', symbolSize: Math.min(36, Math.max(16, deg * 3 + 12)), draggable: true, itemStyle: { color: col, borderColor: '#fff', borderWidth: 2, shadowColor: 'rgba(0,0,0,.1)', shadowBlur: 6, shadowOffsetY: 2 }, label: { show: true, position: 'right', distance: 5, color: '#333', fontSize: 11, fontWeight }, degree: deg } });
const relMap = new Map(); rels.forEach(r => { const k = [r.from, r.to].sort().join('|||'); if (!relMap.has(k)) relMap.set(k, { from: r.from, to: r.to, fromLabel: '', toLabel: '', fromTrend: '', toTrend: '' }); const e = relMap.get(k); if (r.from === e.from) { e.fromLabel = r.label || r.type || ''; e.fromTrend = r.trend || '' } else { e.toLabel = r.label || r.type || ''; e.toTrend = r.trend || '' } });
allLinks = Array.from(relMap.values()).map(r => { const fc = trendColors[r.fromTrend] || '#b8b8b8', tc = trendColors[r.toTrend] || '#b8b8b8'; return { source: r.from, target: r.to, fromName: r.from, toName: r.to, fromLabel: r.fromLabel, toLabel: r.toLabel, fromTrend: r.fromTrend, toTrend: r.toTrend, lineStyle: { width: 1, color: '#d8d8d8', curveness: 0, opacity: 1 }, label: { show: true, position: 'middle', distance: 0, formatter: '{a|◀}{b|▶}', rich: { a: { color: fc, fontSize: 10 }, b: { color: tc, fontSize: 10 } }, align: 'center', verticalAlign: 'middle', offset: [0, -0.1] }, emphasis: { lineStyle: { width: 1.5, color: '#aaa' }, label: { fontSize: 11 } } } });
if (!allNodes.length) { relationChart.clear(); return }
const updateChart = (nodes, links, focusId = null) => { const fadeOpacity = 0.2; const processedNodes = focusId ? nodes.map(n => { const rl = links.filter(l => l.source === focusId || l.target === focusId); const rn = new Set([focusId]); rl.forEach(l => { rn.add(l.source); rn.add(l.target) }); const isRelated = rn.has(n.id); return { ...n, itemStyle: { ...n.itemStyle, opacity: isRelated ? 1 : fadeOpacity }, label: { ...n.label, opacity: isRelated ? 1 : fadeOpacity } } }) : nodes; const processedLinks = focusId ? links.map(l => { const isRelated = l.source === focusId || l.target === focusId; return { ...l, lineStyle: { ...l.lineStyle, opacity: isRelated ? 1 : fadeOpacity }, label: { ...l.label, opacity: isRelated ? 1 : fadeOpacity } } }) : links; relationChart.setOption({ backgroundColor: 'transparent', tooltip: { show: false }, hoverLayerThreshold: Infinity, series: [{ type: 'graph', layout: 'force', roam: true, draggable: true, animation: true, animationDuration: 800, animationDurationUpdate: 300, animationEasingUpdate: 'cubicInOut', progressive: 0, hoverAnimation: false, data: processedNodes, links: processedLinks, force: { initLayout: 'circular', repulsion: 350, edgeLength: [80, 160], gravity: .12, friction: .6, layoutAnimation: true }, label: { show: true }, edgeLabel: { show: true, position: 'middle' }, emphasis: { disabled: true } }] }) };
updateChart(allNodes, allLinks);
setTimeout(() => relationChart.resize(), 0);
relationChart.off('click'); relationChart.on('click', p => { if (p.dataType === 'node') { hideRelationTooltip(); const id = p.data.id; selectCharacter(id); updateChart(allNodes, allLinks, id) } else if (p.dataType === 'edge') { const d = p.data, e = p.event?.event; if (e) { const rect = dom.getBoundingClientRect(); showRelationTooltip(d.fromName, d.toName, d.fromLabel, d.toLabel, d.fromTrend, d.toTrend, e.offsetX || (e.clientX - rect.left), e.offsetY || (e.clientY - rect.top), dom) } } });
relationChart.getZr().on('click', p => { if (!p.target) { hideRelationTooltip(); updateChart(allNodes, allLinks) } });
}
function selectCharacter(id) { currentCharacterId = id; const txt = $('sel-char-text'), opts = $('char-sel-opts'); if (opts && id) { opts.querySelectorAll('.sel-opt').forEach(o => { if (o.dataset.value === id) { o.classList.add('sel'); if (txt) txt.textContent = o.textContent } else o.classList.remove('sel') }) } else if (!id && txt) txt.textContent = '选择角色'; renderCharacterProfile(); if (relationChart && id) { const opt = relationChart.getOption(), idx = opt?.series?.[0]?.data?.findIndex(n => n.id === id || n.name === id); if (idx >= 0) relationChart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: idx }) } }
function updateCharacterSelector(arcs) { const opts = $('char-sel-opts'), txt = $('sel-char-text'); if (!opts) return; if (!arcs?.length) { opts.innerHTML = '<div class="sel-opt" data-value="">暂无角色</div>'; if (txt) txt.textContent = '暂无角色'; currentCharacterId = null; return } opts.innerHTML = arcs.map(a => `<div class="sel-opt" data-value="${h(a.id || a.name)}">${h(a.name || '角色')}</div>`).join(''); opts.querySelectorAll('.sel-opt').forEach(o => o.onclick = e => { e.stopPropagation(); if (o.dataset.value) { selectCharacter(o.dataset.value); $('char-sel').classList.remove('open') } }); if (currentCharacterId && arcs.some(a => (a.id || a.name) === currentCharacterId)) selectCharacter(currentCharacterId); else if (arcs.length) selectCharacter(arcs[0].id || arcs[0].name) }
function renderCharacterProfile() {
const c = $('profile-content'), arcs = summaryData.arcs || [], rels = summaryData.characters?.relationships || []; if (!currentCharacterId || !arcs.length) { c.innerHTML = '<div class="empty">暂无角色数据</div>'; return } const arc = arcs.find(a => (a.id || a.name) === currentCharacterId); if (!arc) { c.innerHTML = '<div class="empty">未找到角色数据</div>'; return } const name = arc.name || '角色', moments = (arc.moments || arc.beats || []).map(m => typeof m === 'string' ? m : m.text), outRels = rels.filter(r => r.from === name), inRels = rels.filter(r => r.to === name);
const sName = h(name), sTraj = h(arc.trajectory || arc.phase || '');
c.innerHTML = `<div class="prof-arc"><div><div class="prof-name">${sName}</div><div class="prof-traj">${sTraj}</div></div><div class="prof-prog-wrap"><div class="prof-prog-lbl"><span>弧光进度</span><span>${Math.round((arc.progress || 0) * 100)}%</span></div><div class="prof-prog"><div class="prof-prog-inner" style="width:${(arc.progress || 0) * 100}%"></div></div></div>${moments.length ? `<div class="prof-moments"><div class="prof-moments-title">关键时刻</div>${moments.map(m => `<div class="prof-moment">${h(m)}</div>`).join('')}</div>` : ''}</div><div class="prof-rels"><div class="rels-group"><div class="rels-group-title">${sName}对别人的羁绊:</div>${outRels.length ? outRels.map(r => `<div class="rel-item"><span class="rel-target">对${h(r.to)}</span><span class="rel-label">${h(r.label || '—')}</span>${r.trend ? `<span class="rel-trend ${trendClass[r.trend] || ''}">${h(r.trend)}</span>` : ''}</div>`).join('') : '<div class="empty" style="padding:16px">暂无关系记录</div>'}</div><div class="rels-group"><div class="rels-group-title">别人对${sName}的羁绊:</div>${inRels.length ? inRels.map(r => `<div class="rel-item"><span class="rel-target">${h(r.from)}</span><span class="rel-label">${h(r.label || '—')}</span>${r.trend ? `<span class="rel-trend ${trendClass[r.trend] || ''}">${h(r.trend)}</span>` : ''}</div>`).join('') : '<div class="empty" style="padding:16px">暂无关系记录</div>'}</div></div>`
}
function renderArcs(arcs) { summaryData.arcs = arcs || []; updateCharacterSelector(arcs || []); renderCharacterProfile() }
function updateStats(s) { if (!s) return; $('stat-summarized').textContent = s.summarizedUpTo ?? 0; $('stat-events').textContent = s.eventsCount ?? 0; const p = s.pendingFloors ?? 0; $('stat-pending').textContent = p; $('pending-warning').classList.toggle('hidden', p !== -1) }
function openRelationsFullscreen() { const m = $('rel-fs-modal'); m.classList.add('active'); const dom = $('relation-chart-fullscreen'); if (!relationChartFullscreen) relationChartFullscreen = echarts.init(dom); if (!allNodes.length) { relationChartFullscreen.clear(); return } relationChartFullscreen.setOption({ tooltip: { show: false }, hoverLayerThreshold: Infinity, series: [{ type: 'graph', layout: 'force', roam: true, draggable: true, animation: true, animationDuration: 800, animationDurationUpdate: 300, animationEasingUpdate: 'cubicInOut', progressive: 0, hoverAnimation: false, data: allNodes.map(n => ({ ...n, symbolSize: Array.isArray(n.symbolSize) ? [n.symbolSize[0] * 1.3, n.symbolSize[1] * 1.3] : n.symbolSize * 1.3, label: { ...n.label, fontSize: 14 } })), links: allLinks.map(l => ({ ...l, label: { ...l.label, fontSize: 18 } })), force: { repulsion: 700, edgeLength: [150, 280], gravity: .06, friction: .6, layoutAnimation: true }, label: { show: true }, edgeLabel: { show: true, position: 'middle' }, emphasis: { disabled: true } }] }); setTimeout(() => relationChartFullscreen.resize(), 100); postMsg('FULLSCREEN_OPENED') }
function closeRelationsFullscreen() { $('rel-fs-modal').classList.remove('active'); postMsg('FULLSCREEN_CLOSED') }
const createDelBtn = () => { const b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-sm btn-del'; b.textContent = '删除'; return b };
function addDeleteHandler(item) { const del = createDelBtn(); (item.querySelector('.struct-actions') || item).appendChild(del); del.onclick = () => item.remove() }
function renderEventsEditor(events) { const list = events?.length ? events : [{ id: 'evt-1', title: '', timeLabel: '', summary: '', participants: [], type: '日常', weight: '点睛' }]; let maxId = 0; list.forEach(e => { const m = e.id?.match(/evt-(\d+)/); if (m) maxId = Math.max(maxId, +m[1]) }); const es = $('editor-struct'); es.innerHTML = list.map(ev => { const id = ev.id || `evt-${++maxId}`; return `<div class="struct-item event-item" data-id="${h(id)}"><div class="struct-row"><input type="text" class="event-title" placeholder="事件标题" value="${h(ev.title || '')}"><input type="text" class="event-time" placeholder="时间标签" value="${h(ev.timeLabel || '')}"></div><div class="struct-row"><textarea class="event-summary" rows="2" placeholder="一句话描述">${h(ev.summary || '')}</textarea></div><div class="struct-row"><input type="text" class="event-participants" placeholder="人物(顿号分隔)" value="${h((ev.participants || []).join('、'))}"></div><div class="struct-row"><select class="event-type">${['相遇', '冲突', '揭示', '抉择', '羁绊', '转变', '收束', '日常'].map(t => `<option ${ev.type === t ? 'selected' : ''}>${t}</option>`).join('')}</select><select class="event-weight">${['核心', '主线', '转折', '点睛', '氛围'].map(t => `<option ${ev.weight === t ? 'selected' : ''}>${t}</option>`).join('')}</select></div><div class="struct-actions"><span>ID${h(id)}</span></div></div>` }).join('') + '<div style="margin-top:8px"><button type="button" class="btn btn-sm" id="event-add"> 新增事件</button></div>'; es.querySelectorAll('.event-item').forEach(addDeleteHandler); $('event-add').onclick = () => { let nmax = maxId; es.querySelectorAll('.event-item').forEach(it => { const m = it.dataset.id?.match(/evt-(\d+)/); if (m) nmax = Math.max(nmax, +m[1]) }); const nid = `evt-${nmax + 1}`, div = document.createElement('div'); div.className = 'struct-item event-item'; div.dataset.id = nid; div.innerHTML = `<div class="struct-row"><input type="text" class="event-title" placeholder="事件标题"><input type="text" class="event-time" placeholder="时间标签"></div><div class="struct-row"><textarea class="event-summary" rows="2" placeholder="一句话描述"></textarea></div><div class="struct-row"><input type="text" class="event-participants" placeholder="人物(顿号分隔)"></div><div class="struct-row"><select class="event-type">${['相遇', '冲突', '揭示', '抉择', '羁绊', '转变', '收束', '日常'].map(t => `<option>${t}</option>`).join('')}</select><select class="event-weight">${['核心', '主线', '转折', '点睛', '氛围'].map(t => `<option>${t}</option>`).join('')}</select></div><div class="struct-actions"><span>ID${h(nid)}</span></div>`; addDeleteHandler(div); es.insertBefore(div, $('event-add').parentElement) } }
function renderCharactersEditor(data) { const d = data || { main: [], relationships: [] }, main = (d.main || []).map(getCharName), rels = d.relationships || []; const es = $('editor-struct'); const trendOpts = ['破裂', '厌恶', '反感', '陌生', '投缘', '亲密', '交融']; es.innerHTML = `<div class="struct-item"><div class="struct-row"><strong>角色列表</strong></div><div id="char-main-list">${(main.length ? main : ['']).map(n => `<div class="struct-row char-main-item"><input type="text" class="char-main-name" placeholder="角色名" value="${h(n || '')}"></div>`).join('')}</div><div style="margin-top:8px"><button type="button" class="btn btn-sm" id="char-main-add"> 新增角色</button></div></div><div class="struct-item"><div class="struct-row"><strong>人物关系</strong></div><div id="char-rel-list">${(rels.length ? rels : [{ from: '', to: '', label: '', trend: '陌生' }]).map(r => `<div class="struct-row char-rel-item"><input type="text" class="char-rel-from" placeholder="角色 A" value="${h(r.from || '')}"><input type="text" class="char-rel-to" placeholder="角色 B" value="${h(r.to || '')}"><input type="text" class="char-rel-label" placeholder="关系" value="${h(r.label || '')}"><select class="char-rel-trend">${trendOpts.map(t => `<option ${r.trend === t ? 'selected' : ''}>${t}</option>`).join('')}</select></div>`).join('')}</div><div style="margin-top:8px"><button type="button" class="btn btn-sm" id="char-rel-add"> </button></div></div>`; es.querySelectorAll('.char-main-item,.char-rel-item').forEach(addDeleteHandler); $('char-main-add').onclick = () => { const div = document.createElement('div'); div.className = 'struct-row char-main-item'; div.innerHTML = '<input type="text" class="char-main-name" placeholder="">'; addDeleteHandler(div); $('char-main-list').appendChild(div) }; $('char-rel-add').onclick = () => { const div = document.createElement('div'); div.className = 'struct-row char-rel-item'; div.innerHTML = `<input type="text" class="char-rel-from" placeholder=" A"><input type="text" class="char-rel-to" placeholder=" B"><input type="text" class="char-rel-label" placeholder=""><select class="char-rel-trend">${trendOpts.map(t => `<option>${t}</option>`).join('')}</select>`; addDeleteHandler(div); $('char-rel-list').appendChild(div) } }
function renderArcsEditor(arcs) { const list = arcs?.length ? arcs : [{ name: '', trajectory: '', progress: 0, moments: [] }]; const es = $('editor-struct'); es.innerHTML = `<div id="arc-list">${list.map((a, i) => `<div class="struct-item arc-item" data-index="${i}"><div class="struct-row"><input type="text" class="arc-name" placeholder="角色名" value="${h(a.name || '')}"></div><div class="struct-row"><textarea class="arc-trajectory" rows="2" placeholder="当前状态描述">${h(a.trajectory || '')}</textarea></div><div class="struct-row"><label style="font-size:.75rem;color:var(--txt3)">进度:<input type="number" class="arc-progress" min="0" max="100" value="${Math.round((a.progress || 0) * 100)}" style="width:64px;display:inline-block"> %</label></div><div class="struct-row"><textarea class="arc-moments" rows="3" placeholder="关键时刻,一行一个">${h((a.moments || []).map(m => typeof m === 'string' ? m : m.text).join('\n'))}</textarea></div><div class="struct-actions"><span>角色弧光 ${i + 1}</span></div></div>`).join('')}</div><div style="margin-top:8px"><button type="button" class="btn btn-sm" id="arc-add"> 新增角色弧光</button></div>`; es.querySelectorAll('.arc-item').forEach(addDeleteHandler); $('arc-add').onclick = () => { const listEl = $('arc-list'), idx = listEl.querySelectorAll('.arc-item').length, div = document.createElement('div'); div.className = 'struct-item arc-item'; div.dataset.index = idx; div.innerHTML = `<div class="struct-row"><input type="text" class="arc-name" placeholder="角色名"></div><div class="struct-row"><textarea class="arc-trajectory" rows="2" placeholder="当前状态描述"></textarea></div><div class="struct-row"><label style="font-size:.75rem;color:var(--txt3)">进度:<input type="number" class="arc-progress" min="0" max="100" value="0" style="width:64px;display:inline-block"> %</label></div><div class="struct-row"><textarea class="arc-moments" rows="3" placeholder="关键时刻,一行一个"></textarea></div><div class="struct-actions"><span>角色弧光 ${idx + 1}</span></div>`; addDeleteHandler(div); listEl.appendChild(div) } }
function openEditor(section) { currentEditSection = section; const meta = sectionMeta[section], es = $('editor-struct'), ta = $('editor-ta'); $('editor-title').textContent = meta.title; $('editor-hint').textContent = meta.hint; $('editor-err').classList.remove('visible'); $('editor-err').textContent = ''; es.classList.add('hidden'); ta.classList.remove('hidden'); if (section === 'keywords') ta.value = summaryData.keywords.map(k => `${k.text}|${k.weight || '一般'}`).join('\n'); else { ta.classList.add('hidden'); es.classList.remove('hidden'); if (section === 'events') renderEventsEditor(summaryData.events || []); else if (section === 'characters') renderCharactersEditor(summaryData.characters || { main: [], relationships: [] }); else if (section === 'arcs') renderArcsEditor(summaryData.arcs || []) } $('editor-modal').classList.add('active'); postMsg('EDITOR_OPENED') }
function closeEditor() { $('editor-modal').classList.remove('active'); currentEditSection = null; postMsg('EDITOR_CLOSED') }
function saveEditor() { const section = currentEditSection, es = $('editor-struct'), ta = $('editor-ta'); let parsed; try { if (section === 'keywords') { const oldMap = new Map((summaryData.keywords || []).map(k => [k.text, k])); parsed = ta.value.trim().split('\n').filter(l => l.trim()).map(line => { const [text, weight] = line.split('|').map(s => s.trim()); return preserveAddedAt({ text: text || '', weight: weight || '一般' }, oldMap.get(text)) }) } else if (section === 'events') { const oldMap = new Map((summaryData.events || []).map(e => [e.id, e])); parsed = Array.from(es.querySelectorAll('.event-item')).map(it => { const id = it.dataset.id; return preserveAddedAt({ id, title: it.querySelector('.event-title').value.trim(), timeLabel: it.querySelector('.event-time').value.trim(), summary: it.querySelector('.event-summary').value.trim(), participants: it.querySelector('.event-participants').value.trim().split(/[,、,]/).map(s => s.trim()).filter(Boolean), type: it.querySelector('.event-type').value, weight: it.querySelector('.event-weight').value }, oldMap.get(id)) }).filter(e => e.title || e.summary) } else if (section === 'characters') { const oldMainMap = new Map((summaryData.characters?.main || []).map(m => [getCharName(m), m])), mainNames = Array.from(es.querySelectorAll('.char-main-name')).map(i => i.value.trim()).filter(Boolean), main = mainNames.map(n => preserveAddedAt({ name: n }, oldMainMap.get(n))); const oldRelMap = new Map((summaryData.characters?.relationships || []).map(r => [`${r.from}->${r.to}`, r])), rels = Array.from(es.querySelectorAll('.char-rel-item')).map(it => { const from = it.querySelector('.char-rel-from').value.trim(), to = it.querySelector('.char-rel-to').value.trim(); return preserveAddedAt({ from, to, label: it.querySelector('.char-rel-label').value.trim(), trend: it.querySelector('.char-rel-trend').value }, oldRelMap.get(`${from}->${to}`)) }).filter(r => r.from && r.to); parsed = { main, relationships: rels } } else if (section === 'arcs') { const oldArcMap = new Map((summaryData.arcs || []).map(a => [a.name, a])); parsed = Array.from(es.querySelectorAll('.arc-item')).map(it => { const name = it.querySelector('.arc-name').value.trim(), oldArc = oldArcMap.get(name), oldMomentMap = new Map((oldArc?.moments || []).map(m => [typeof m === 'string' ? m : m.text, m])), momentsRaw = it.querySelector('.arc-moments').value.trim(), moments = momentsRaw ? momentsRaw.split('\n').map(s => s.trim()).filter(Boolean).map(t => preserveAddedAt({ text: t }, oldMomentMap.get(t))) : []; return preserveAddedAt({ name, trajectory: it.querySelector('.arc-trajectory').value.trim(), progress: Math.max(0, Math.min(1, (parseFloat(it.querySelector('.arc-progress').value) || 0) / 100)), moments }, oldArc) }).filter(a => a.name || a.trajectory || a.moments?.length) } } catch (e) { $('editor-err').textContent = `格式错误: ${e.message}`; $('editor-err').classList.add('visible'); return } postMsg('UPDATE_SECTION', { section, data: parsed }); if (section === 'keywords') renderKeywords(parsed); else if (section === 'events') { renderTimeline(parsed); $('stat-events').textContent = parsed.length } else if (section === 'characters') renderRelations(parsed); else if (section === 'arcs') renderArcs(parsed); closeEditor() }
function updateProviderUI(provider) { const pv = providerDefaults[provider] || providerDefaults.custom, isSt = provider === 'st'; $('api-url-row').classList.toggle('hidden', isSt); $('api-key-row').classList.toggle('hidden', !pv.needKey); $('api-model-manual-row').classList.toggle('hidden', isSt || !pv.needManualModel); $('api-model-select-row').classList.toggle('hidden', isSt || pv.needManualModel || !config.api.modelCache.length); $('api-connect-row').classList.toggle('hidden', isSt || !pv.canFetch); const urlInput = $('api-url'); if (!urlInput.value && pv.url) urlInput.value = pv.url }
function openSettings() { const pn = id => { const v = $(id).value; return v === '' ? null : parseFloat(v) }; $('api-provider').value = config.api.provider; $('api-url').value = config.api.url; $('api-key').value = config.api.key; $('api-model-text').value = config.api.model; $('gen-temp').value = config.gen.temperature ?? ''; $('gen-top-p').value = config.gen.top_p ?? ''; $('gen-top-k').value = config.gen.top_k ?? ''; $('gen-presence').value = config.gen.presence_penalty ?? ''; $('gen-frequency').value = config.gen.frequency_penalty ?? ''; $('trigger-enabled').checked = config.trigger.enabled; $('trigger-interval').value = config.trigger.interval; $('trigger-timing').value = config.trigger.timing; $('trigger-stream').checked = config.trigger.useStream !== false; $('trigger-max-per-run').value = config.trigger.maxPerRun || 100; const en = $('trigger-enabled'); if (config.trigger.timing === 'manual') { en.checked = false; en.disabled = true; en.parentElement.style.opacity = '.5' } else { en.disabled = false; en.parentElement.style.opacity = '1' } if (config.api.modelCache.length) { $('api-model-select').innerHTML = config.api.modelCache.map(m => `<option value="${m}"${m === config.api.model ? ' selected' : ''}>${m}</option>`).join('') } updateProviderUI(config.api.provider); $('settings-modal').classList.add('active'); postMsg('SETTINGS_OPENED') }
function closeSettings(save) { if (save) { const pn = id => { const v = $(id).value; return v === '' ? null : parseFloat(v) }; const provider = $('api-provider').value, pv = providerDefaults[provider] || providerDefaults.custom; config.api.provider = provider; config.api.url = $('api-url').value; config.api.key = $('api-key').value; config.api.model = provider === 'st' ? '' : pv.needManualModel ? $('api-model-text').value : $('api-model-select').value; config.gen.temperature = pn('gen-temp'); config.gen.top_p = pn('gen-top-p'); config.gen.top_k = pn('gen-top-k'); config.gen.presence_penalty = pn('gen-presence'); config.gen.frequency_penalty = pn('gen-frequency'); const timing = $('trigger-timing').value; config.trigger.timing = timing; config.trigger.enabled = timing === 'manual' ? false : $('trigger-enabled').checked; config.trigger.interval = parseInt($('trigger-interval').value) || 20; config.trigger.useStream = $('trigger-stream').checked; config.trigger.maxPerRun = parseInt($('trigger-max-per-run').value) || 100; saveConfig() } $('settings-modal').classList.remove('active'); postMsg('SETTINGS_CLOSED') }
async function fetchModels() { const btn = $('btn-connect'), provider = $('api-provider').value; if (!providerDefaults[provider]?.canFetch) { alert('当前渠道不支持自动拉取模型'); return } let baseUrl = $('api-url').value.trim().replace(/\/+$/, ''); const apiKey = $('api-key').value.trim(); if (!apiKey) { alert('请先填写 API KEY'); return } btn.disabled = true; btn.textContent = '连接中...'; try { const tryFetch = async url => { const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' } }); return res.ok ? (await res.json())?.data?.map(m => m?.id).filter(Boolean) || null : null }; if (baseUrl.endsWith('/v1')) baseUrl = baseUrl.slice(0, -3); let models = await tryFetch(`${baseUrl}/v1/models`); if (!models) models = await tryFetch(`${baseUrl}/models`); if (!models?.length) throw new Error('未获取到模型列表'); config.api.modelCache = [...new Set(models)]; const sel = $('api-model-select'); sel.innerHTML = config.api.modelCache.map(m => `<option value="${m}">${m}</option>`).join(''); $('api-model-select-row').classList.remove('hidden'); if (!config.api.model && models.length) { config.api.model = models[0]; sel.value = models[0] } else if (config.api.model) sel.value = config.api.model; saveConfig(); alert(`成功获取 ${models.length} 个模型`) } catch (e) { alert('连接失败:' + (e.message || '请检查 URL 和 KEY')) } finally { btn.disabled = false; btn.textContent = '连接 / 拉取模型列表' } }
$$('.sec-btn[data-section]').forEach(b => b.onclick = () => openEditor(b.dataset.section));
$('editor-backdrop').onclick = $('editor-close').onclick = $('editor-cancel').onclick = closeEditor;
$('editor-save').onclick = saveEditor;
$('btn-settings').onclick = openSettings;
$('settings-backdrop').onclick = $('settings-close').onclick = $('settings-cancel').onclick = () => closeSettings(false);
$('settings-save').onclick = () => closeSettings(true);
$('api-provider').onchange = e => { const pv = providerDefaults[e.target.value]; $('api-url').value = ''; if (!pv.canFetch) config.api.modelCache = []; updateProviderUI(e.target.value) };
$('btn-connect').onclick = fetchModels;
$('api-model-select').onchange = e => { config.api.model = e.target.value };
$('btn-clear').onclick = () => postMsg('REQUEST_CLEAR');
$('btn-generate').onclick = () => { const btn = $('btn-generate'); if (!localGenerating) { localGenerating = true; btn.textContent = '停止'; postMsg('REQUEST_GENERATE', { config: { api: config.api, gen: config.gen, trigger: config.trigger } }) } else { localGenerating = false; btn.textContent = '总结'; postMsg('REQUEST_CANCEL') } };
$('hide-summarized').onchange = e => postMsg('TOGGLE_HIDE_SUMMARIZED', { enabled: e.target.checked });
$('keep-visible-count').onchange = e => { const c = Math.max(0, Math.min(50, parseInt(e.target.value) || 3)); e.target.value = c; postMsg('UPDATE_KEEP_VISIBLE', { count: c }) };
$('btn-fullscreen-relations').onclick = openRelationsFullscreen;
$('rel-fs-backdrop').onclick = $('rel-fs-close').onclick = closeRelationsFullscreen;
$('char-sel-trigger').onclick = e => { e.stopPropagation(); $('char-sel').classList.toggle('open') };
document.onclick = e => { const cs = $('char-sel'); if (cs && !cs.contains(e.target)) cs.classList.remove('open') };
$('trigger-timing').onchange = e => { const en = $('trigger-enabled'); if (e.target.value === 'manual') { en.checked = false; en.disabled = true; en.parentElement.style.opacity = '.5' } else { en.disabled = false; en.parentElement.style.opacity = '1' } };
window.onresize = () => { relationChart?.resize(); relationChartFullscreen?.resize() };
// Guarded by origin/source check.
window.onmessage = e => { if (e.origin !== PARENT_ORIGIN || e.source !== window.parent) return; const d = e.data; if (!d || d.source !== 'LittleWhiteBox') return; const btn = $('btn-generate'); switch (d.type) { case 'GENERATION_STATE': localGenerating = !!d.isGenerating; btn.textContent = localGenerating ? '停止' : '总结'; break; case 'SUMMARY_BASE_DATA': if (d.stats) { updateStats(d.stats); $('summarized-count').textContent = d.stats.hiddenCount ?? 0 } if (d.hideSummarized !== undefined) $('hide-summarized').checked = d.hideSummarized; if (d.keepVisibleCount !== undefined) $('keep-visible-count').value = d.keepVisibleCount; break; case 'SUMMARY_FULL_DATA': if (d.payload) { const p = d.payload; if (p.keywords) renderKeywords(p.keywords); if (p.events) renderTimeline(p.events); if (p.characters) renderRelations(p.characters); if (p.arcs) renderArcs(p.arcs); $('stat-events').textContent = p.events?.length || 0; if (p.lastSummarizedMesId != null) $('stat-summarized').textContent = p.lastSummarizedMesId + 1; if (p.stats) updateStats(p.stats) } break; case 'SUMMARY_ERROR': console.error('Summary error:', d.message); break; case 'SUMMARY_CLEARED': const t = d.payload?.totalFloors || 0; $('stat-events').textContent = 0; $('stat-summarized').textContent = 0; $('stat-pending').textContent = t; $('summarized-count').textContent = 0; summaryData = { keywords: [], events: [], characters: { main: [], relationships: [] }, arcs: [] }; renderKeywords([]); renderTimeline([]); renderRelations(null); renderArcs([]); break; case 'LOAD_PANEL_CONFIG': if (d.config) { applyConfig(d.config); } break } };
document.addEventListener('DOMContentLoaded', () => { loadConfig(); $('stat-events').textContent = '—'; $('stat-summarized').textContent = '—'; $('stat-pending').textContent = '—'; $('summarized-count').textContent = '0'; renderKeywords([]); renderTimeline([]); renderArcs([]); postMsg('FRAME_READY') });
</script>
</body>
</html>