Files
writing-agent/web/preview-arena-cards.html
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 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:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00

650 lines
23 KiB
HTML
Raw Permalink 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" 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
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>