预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
This commit is contained in:
@@ -0,0 +1,649 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-color-theme="green">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>预览 · 气泡下的模型卡片</title>
|
||||
<style>
|
||||
:root {
|
||||
--st-bg: #dce5d9;
|
||||
--st-surface: #f4f9f2;
|
||||
--st-border: #c0cfbb;
|
||||
--st-border-strong: #a8bca2;
|
||||
--st-text: #2b3330;
|
||||
--st-muted: #65716c;
|
||||
--st-faint: #7a8580;
|
||||
--st-accent: #6f8e6c;
|
||||
--st-accent-soft: rgba(125, 155, 122, 0.15);
|
||||
--st-danger: #9a4038;
|
||||
--st-font-ui: "Noto Sans SC", "PingFang SC", "Microsoft YaHei UI", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--st-bg);
|
||||
color: var(--st-text);
|
||||
font-family: var(--st-font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.room {
|
||||
height: 100%;
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: color-mix(in srgb, var(--st-bg) 72%, var(--st-surface));
|
||||
border-left: 1px solid var(--st-border);
|
||||
border-right: 1px solid var(--st-border);
|
||||
}
|
||||
|
||||
.preview-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--st-border);
|
||||
background: var(--st-surface);
|
||||
font-size: 12px;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.preview-bar strong {
|
||||
font-weight: 650;
|
||||
color: var(--st-text);
|
||||
}
|
||||
|
||||
.transcript {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 16px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.turn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--st-accent) 28%, var(--st-border));
|
||||
background: color-mix(in srgb, var(--st-accent-soft) 70%, var(--st-surface));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bubble-time {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--st-faint);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.strip-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.strip-hint {
|
||||
font-size: 11px;
|
||||
color: var(--st-faint);
|
||||
}
|
||||
|
||||
.strip-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.idx {
|
||||
min-width: 3.2rem;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--st-muted);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--st-muted);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-btn:hover,
|
||||
.nav-btn:focus-visible {
|
||||
color: var(--st-accent);
|
||||
background: var(--st-accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nav-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
color: var(--st-faint);
|
||||
}
|
||||
|
||||
.track {
|
||||
--card-gap: 10px;
|
||||
--card-peek: 36px;
|
||||
--card-min-h: 120px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--card-gap);
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-inline: 0;
|
||||
padding-bottom: 2px;
|
||||
cursor: grab;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.track.is-dragging {
|
||||
cursor: grabbing;
|
||||
scroll-snap-type: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex: 0 0 calc((100% - var(--card-gap) - var(--card-peek)) / 2);
|
||||
width: calc((100% - var(--card-gap) - var(--card-peek)) / 2);
|
||||
scroll-snap-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: var(--card-min-h);
|
||||
border: 1px solid var(--st-border);
|
||||
border-radius: 8px;
|
||||
background: var(--st-surface);
|
||||
}
|
||||
|
||||
.track[data-count="1"] .card {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card.is-collapsed .card-text {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card.is-selected {
|
||||
border-color: var(--st-accent);
|
||||
background: color-mix(in srgb, var(--st-accent-soft) 55%, var(--st-surface));
|
||||
}
|
||||
|
||||
.card.is-error {
|
||||
border-color: color-mix(in srgb, var(--st-danger) 45%, var(--st-border));
|
||||
}
|
||||
|
||||
.card-head {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--st-border);
|
||||
}
|
||||
|
||||
.card-titles { min-width: 0; }
|
||||
|
||||
.card-name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-model {
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: var(--st-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.card-body.is-error { color: var(--st-danger); }
|
||||
.card-body.is-pending { color: var(--st-faint); }
|
||||
|
||||
.card-foot {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
|
||||
.card-foot:not(:has(button:not([hidden]))) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
margin-right: auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--st-muted);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expand-btn:hover,
|
||||
.expand-btn:focus-visible {
|
||||
color: var(--st-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select-btn,
|
||||
.remove-btn {
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--st-surface);
|
||||
color: var(--st-text);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-btn:hover,
|
||||
.remove-btn:hover,
|
||||
.select-btn:focus-visible,
|
||||
.remove-btn:focus-visible {
|
||||
border-color: var(--st-accent);
|
||||
background: var(--st-accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select-btn.is-on {
|
||||
border-color: var(--st-accent);
|
||||
background: var(--st-accent-soft);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.composer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 16px 14px;
|
||||
border-top: 1px solid var(--st-border);
|
||||
background: var(--st-surface);
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
min-height: 38px;
|
||||
max-height: 120px;
|
||||
resize: none;
|
||||
border: 1px solid var(--st-border-strong);
|
||||
border-radius: 8px;
|
||||
background: var(--st-bg);
|
||||
color: var(--st-faint);
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.send {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--st-accent);
|
||||
color: #f4f9f2;
|
||||
font: inherit;
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="room">
|
||||
<div class="preview-bar">
|
||||
<span><strong>临时会话</strong> · 预览,回复都是字符串</span>
|
||||
<span>同一条消息只出现一次,卡片可左右拖</span>
|
||||
</div>
|
||||
<div class="transcript" id="transcript"></div>
|
||||
<footer class="composer">
|
||||
<textarea rows="1" readonly placeholder="预览不发送。正式页仍是底部这一条输入。"></textarea>
|
||||
<button type="button" class="send" disabled aria-label="发送">↑</button>
|
||||
</footer>
|
||||
</div>
|
||||
<script>
|
||||
const windows = [
|
||||
{ id: "w1", name: "claude-haiku-5.5", model: "anthropic/claude-haiku-5.5" },
|
||||
{ id: "w2", name: "gemini-3.8-flash", model: "google/gemini-3.8-flash" },
|
||||
{ id: "w3", name: "gpt-5.4-mini", model: "openai/gpt-5.4-mini" },
|
||||
{ id: "w4", name: "deepseek-v4", model: "deepseek/deepseek-v4" },
|
||||
{ id: "w5", name: "claude-sonnet-5", model: "anthropic/claude-sonnet-5" },
|
||||
];
|
||||
|
||||
const turns = [
|
||||
{
|
||||
id: "t1",
|
||||
time: "21:06",
|
||||
user: "开篇别写天气。林晚推门进来,屋里已经有人。收短,留下一句她没问出口的话。",
|
||||
selected: "w2",
|
||||
replies: {
|
||||
w1: "门轴响了一下。林晚进门,外套还挂在臂上,肩上的水珠顺着袖口往下掉。桌边坐着的人抬了抬眼,又低下去,手指停在杯沿上。她把钥匙放进碟子,金属碰了一声。那句「你怎么在这」到了舌尖,她用舌头顶回去,只伸手去摘围巾。围巾结打得紧,她解了两次才松开,穗子扫过桌面,拂开一张折过的纸。纸角朝上,她看见自己的名字,没有去拿。",
|
||||
w2: "林晚推门时灯已经亮着。那人坐在桌边,面前一只空杯子,杯底还沾着一圈茶渍。她把门带上,声音比想的轻,锁舌却还是响了。钥匙落进碟子里。她站在门边,鞋跟没有再往前。话到了嘴边,她侧过身去烧水,壶盖磕在灶沿上,那句话就停在水壶的响声里。水开之前谁都没说话。她背对着那人,手指扣着灶台的边,扣得指节发白。",
|
||||
w3: "林晚推开门。屋里的灯亮着,桌边已经坐了一个人,面前是一只空杯子。她把钥匙放进门口的碟子,金属碰了一声,手指在碟沿上停了一停。那句问话到了嘴边,没有出口。她解下围巾,搭到椅背上,手还按着木茬,人站在椅子侧面,没有往桌边走。那人把杯子转了半圈。林晚看着杯底画出的水印,终于把另一只手也放上椅背,围巾的穗子从指缝里滑下去。",
|
||||
w4: "门开着一条缝,光从里面漏出来。林晚侧身进去,看见那人的背影对着窗。她张了张嘴,喉咙里那句话转了一圈,最后只变成解围巾的动作。围巾搭上椅背,她的手还按着,没有拿开。椅背是凉的。那人没有回头,只把空杯子转了半圈。林晚看着那只杯子,又看着自己按在椅背上的手,终于把另一只手也放上去,像是要说什么,到底只把围巾的穗子理齐。",
|
||||
w5: { error: "上游超时,这条没有回复。" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
time: "21:11",
|
||||
user: "她没问出口的那句,换成一个动作。别解释她在想什么。",
|
||||
selected: null,
|
||||
replies: {
|
||||
w1: { pending: true },
|
||||
w2: "她把那只空杯子转了半圈,又转回去,杯底在桌面上刮出一声很轻的响。林晚的手还按在椅背上,围巾的穗子从指缝里漏下去。她没有坐。那人这才抬眼,目光停在她的手上,又移开。林晚抽回手,把围巾从椅背上拿起来,重新搭好,动作慢得像在把一句已经到了嘴边的话按回去。",
|
||||
w3: "林晚拉开椅子,椅腿在地板上拖出短短一声。她没有坐下去。手离开椅背,转到水槽边,拧开水龙头。水声起来以后,那人把空杯子往她这边推了推,杯底刮过桌面。她接过杯子,冲了一下,倒扣在沥水架上。水还在流。她伸手去关,指节在龙头上停住,又拧紧,全程没有回头看他。围巾还搭在椅背上,穗子垂到座位上。",
|
||||
w4: "她把围巾搭上椅背,手还按着,没有拿开。指腹压住一块被磨毛的木茬。那人的杯子停在手边,她用空着的那只手把杯子转了半圈,又转回去,水渍在桌上画出一个不完整的圆。圆没有合上,她的手也没有离开椅背。",
|
||||
w5: { error: "上游超时,这条没有回复。" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const alive = new Set(windows.map((w) => w.id));
|
||||
const expanded = new Set();
|
||||
const transcript = document.getElementById("transcript");
|
||||
const MIN_CARD_H = 120;
|
||||
|
||||
/** 至少两张都能撑到的最大高度:两张即最短,更多即次短。 */
|
||||
function sharedReachHeight(heights) {
|
||||
if (!heights.length) return MIN_CARD_H;
|
||||
const sorted = [...heights].sort((a, b) => a - b);
|
||||
const index = Math.min(1, Math.max(0, sorted.length - 2));
|
||||
return Math.max(MIN_CARD_H, sorted[index]);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function bodyOf(reply) {
|
||||
if (!reply) return { cls: "is-pending", text: "本轮无回复" };
|
||||
if (reply.error) return { cls: "is-error", text: reply.error };
|
||||
if (reply.pending) return { cls: "is-pending", text: "正在回复…" };
|
||||
return { cls: "", text: reply };
|
||||
}
|
||||
|
||||
function render() {
|
||||
const list = windows.filter((w) => alive.has(w.id));
|
||||
transcript.innerHTML = turns
|
||||
.map((turn) => {
|
||||
const kept = turn.selected && list.some((w) => w.id === turn.selected && !bodyOf(turn.replies[w.id]).cls) ? turn.selected : null;
|
||||
const shown = kept ? list.filter((w) => w.id === kept) : list;
|
||||
const cards = shown
|
||||
.map((w) => {
|
||||
const body = bodyOf(turn.replies[w.id]);
|
||||
const selected = turn.selected === w.id && !body.cls;
|
||||
const keep =
|
||||
list.length > 1 && body.cls !== "is-error" && body.cls !== "is-pending"
|
||||
? `<button type="button" class="select-btn ${selected ? "is-on" : ""}" data-action="keep" data-turn="${turn.id}" data-window="${w.id}">${selected ? "已保留 · 取消" : "保留此回复"}</button>`
|
||||
: "";
|
||||
const remove =
|
||||
list.length > 1
|
||||
? `<button type="button" class="remove-btn" data-action="remove" data-window="${w.id}">移除</button>`
|
||||
: "";
|
||||
return `<article class="card ${selected ? "is-selected" : ""} ${body.cls === "is-error" ? "is-error" : ""}" data-window="${w.id}">
|
||||
<header class="card-head">
|
||||
<div class="card-titles">
|
||||
<span class="card-name">${esc(w.name)}</span>
|
||||
<span class="card-model">${esc(w.model)}</span>
|
||||
</div>
|
||||
${remove}
|
||||
</header>
|
||||
<div class="card-body ${body.cls}" data-empty="${body.cls ? "1" : "0"}"><div class="card-text">${esc(body.text)}</div></div>
|
||||
<div class="card-foot">
|
||||
<button type="button" class="expand-btn" data-action="expand" data-turn="${turn.id}" data-window="${w.id}" hidden>展开</button>
|
||||
${keep}
|
||||
</div>
|
||||
</article>`;
|
||||
})
|
||||
.join("");
|
||||
const stripBar = kept
|
||||
? ""
|
||||
: `<div class="strip-bar">
|
||||
<span class="strip-hint">左右拖动</span>
|
||||
<div class="strip-nav">
|
||||
<button type="button" class="nav-btn" data-action="prev" data-turn="${turn.id}" aria-label="上一张">‹</button>
|
||||
<span class="idx" data-idx="${turn.id}">1 / ${list.length}</span>
|
||||
<button type="button" class="nav-btn" data-action="next" data-turn="${turn.id}" aria-label="下一张">›</button>
|
||||
</div>
|
||||
</div>`;
|
||||
return `<section class="turn" data-turn="${turn.id}">
|
||||
<div class="user-row">
|
||||
<article class="bubble">
|
||||
<div>${esc(turn.user)}</div>
|
||||
<div class="bubble-time">${esc(turn.time)}</div>
|
||||
</article>
|
||||
</div>
|
||||
${stripBar}
|
||||
<div class="track" data-turn="${turn.id}" data-count="${shown.length}" tabindex="0">${cards}</div>
|
||||
</section>`;
|
||||
})
|
||||
.join("");
|
||||
for (const track of transcript.querySelectorAll(".track")) bindTrack(track);
|
||||
collapseTracks();
|
||||
}
|
||||
|
||||
function collapseTracks() {
|
||||
for (const track of transcript.querySelectorAll(".track")) {
|
||||
const cards = [...track.querySelectorAll(".card")];
|
||||
if (cards.length < 2) {
|
||||
for (const card of cards) card.style.height = "auto";
|
||||
continue;
|
||||
}
|
||||
const measured = cards.map((card) => {
|
||||
card.style.height = "auto";
|
||||
const empty = card.querySelector(".card-body")?.dataset.empty === "1";
|
||||
return { card, h: card.offsetHeight, empty };
|
||||
});
|
||||
const substantive = measured.filter((item) => !item.empty).map((item) => item.h);
|
||||
const target = sharedReachHeight(substantive);
|
||||
for (const { card, h } of measured) {
|
||||
const key = `${track.dataset.turn}:${card.dataset.window}`;
|
||||
const btn = card.querySelector("[data-action='expand']");
|
||||
const open = expanded.has(key);
|
||||
if (open) {
|
||||
card.style.height = "auto";
|
||||
card.classList.remove("is-collapsed");
|
||||
if (btn) {
|
||||
btn.hidden = false;
|
||||
btn.textContent = "收起";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
card.style.height = `${target}px`;
|
||||
card.classList.add("is-collapsed");
|
||||
if (btn) {
|
||||
const overflow = h > target + 1;
|
||||
btn.hidden = !overflow;
|
||||
btn.textContent = "展开";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cardStep(track) {
|
||||
const card = track.querySelector(".card");
|
||||
if (!card) return track.clientWidth;
|
||||
const gap = 10;
|
||||
return card.getBoundingClientRect().width + gap;
|
||||
}
|
||||
|
||||
function updateIdx(track) {
|
||||
const cards = [...track.querySelectorAll(".card")];
|
||||
const turnId = track.dataset.turn;
|
||||
const idx = transcript.querySelector(`[data-idx="${turnId}"]`);
|
||||
if (!idx || !cards.length) return;
|
||||
const step = cardStep(track);
|
||||
const start = Math.min(cards.length - 1, Math.max(0, Math.round(track.scrollLeft / step)));
|
||||
const end = Math.min(cards.length, start + 2);
|
||||
idx.textContent = end > start + 1 ? `${start + 1}–${end} / ${cards.length}` : `${start + 1} / ${cards.length}`;
|
||||
const prev = transcript.querySelector(`[data-action="prev"][data-turn="${turnId}"]`);
|
||||
const next = transcript.querySelector(`[data-action="next"][data-turn="${turnId}"]`);
|
||||
const lastPage = Math.max(0, cards.length - 2);
|
||||
if (prev) prev.disabled = start <= 0;
|
||||
if (next) next.disabled = start >= lastPage;
|
||||
}
|
||||
|
||||
function scrollToIndex(track, index) {
|
||||
const cards = [...track.querySelectorAll(".card")];
|
||||
const card = cards[index];
|
||||
if (!card) return;
|
||||
track.scrollTo({ left: card.offsetLeft - track.offsetLeft, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function bindTrack(track) {
|
||||
let dragging = false;
|
||||
let axis = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startScroll = 0;
|
||||
|
||||
track.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0 || e.target.closest("button")) return;
|
||||
dragging = true;
|
||||
axis = null;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
startScroll = track.scrollLeft;
|
||||
});
|
||||
|
||||
track.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (!axis) {
|
||||
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||
axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
|
||||
if (axis === "x") {
|
||||
track.classList.add("is-dragging");
|
||||
track.setPointerCapture(e.pointerId);
|
||||
}
|
||||
}
|
||||
if (axis === "x") {
|
||||
track.scrollLeft = startScroll - dx;
|
||||
}
|
||||
});
|
||||
|
||||
const end = () => {
|
||||
dragging = false;
|
||||
axis = null;
|
||||
track.classList.remove("is-dragging");
|
||||
};
|
||||
track.addEventListener("pointerup", end);
|
||||
track.addEventListener("pointercancel", end);
|
||||
track.addEventListener("scroll", () => updateIdx(track), { passive: true });
|
||||
updateIdx(track);
|
||||
}
|
||||
|
||||
transcript.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("button");
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
if (action === "expand") {
|
||||
const key = `${btn.dataset.turn}:${btn.dataset.window}`;
|
||||
if (expanded.has(key)) expanded.delete(key);
|
||||
else expanded.add(key);
|
||||
collapseTracks();
|
||||
return;
|
||||
}
|
||||
if (action === "remove") {
|
||||
alive.delete(btn.dataset.window);
|
||||
for (const turn of turns) {
|
||||
if (turn.selected === btn.dataset.window) turn.selected = null;
|
||||
}
|
||||
for (const key of [...expanded]) {
|
||||
if (key.endsWith(`:${btn.dataset.window}`)) expanded.delete(key);
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (action === "keep") {
|
||||
const turn = turns.find((t) => t.id === btn.dataset.turn);
|
||||
if (!turn) return;
|
||||
turn.selected = turn.selected === btn.dataset.window ? null : btn.dataset.window;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (action === "prev" || action === "next") {
|
||||
const track = transcript.querySelector(`.track[data-turn="${btn.dataset.turn}"]`);
|
||||
if (!track) return;
|
||||
const step = cardStep(track);
|
||||
const i = Math.round(track.scrollLeft / step);
|
||||
const lastPage = Math.max(0, track.querySelectorAll(".card").length - 2);
|
||||
const nextIndex = action === "next" ? Math.min(lastPage, i + 1) : Math.max(0, i - 1);
|
||||
scrollToIndex(track, nextIndex);
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user