预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。

- 装配只认 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:
2026-10-23 00:13:08 +08:00
parent ed09e5c831
commit 9dc927282f
94 changed files with 11558 additions and 413 deletions
+649
View File
@@ -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("&", "&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>