- 彻底移除「每 N 次请求强制思考」的间隔调度机制(思考/非思考交替导致缓存命中率极低) - 运行模式收敛为 fast/thinking:原 deep 改名 thinking,历史数据读取时映射 - 新增推理强度五档滑块(low/medium/high/xhigh/max + 默认不传参),置于模型/模式弹窗 - 模型配置新增 reasoning_effort 开关;档位会话级持久化到对话 meta,随对话保存/恢复 - 个人空间新增「默认推理强度」;新用户默认运行模式改为 thinking - 滑块交互:JS 插值驱动(点击/拖动/吸附全程平滑),首帧档位语义定位无闪烁 - 保存链路:前端防抖 600ms + conversation_id 兜底,修复 /new 发消息丢档位、切对话恢复错误
474 lines
13 KiB
HTML
474 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>推理强度选择器 Demo</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
:root {
|
||
--page-bg: #1a1a1c;
|
||
--card-bg: #2c2c2e;
|
||
--track-bg: #3a3a3c;
|
||
--text-primary: #f2f2f4;
|
||
--text-secondary: #9c9ca0;
|
||
--knob-bg: #ffffff;
|
||
--dot-on: rgba(255, 255, 255, 0.55);
|
||
--dot-off: rgba(0, 0, 0, 0.28);
|
||
}
|
||
|
||
html, body {
|
||
height: 100%;
|
||
background: var(--page-bg);
|
||
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
|
||
.demo-title {
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
/* ========== 卡片(模拟弹窗内的一块区域) ========== */
|
||
.effort-card {
|
||
width: 460px;
|
||
background: var(--card-bg);
|
||
border-radius: 18px;
|
||
padding: 22px 26px 20px;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
|
||
/* ========== 顶部:两种状态文字叠在一起,淡入淡出切换 ========== */
|
||
.effort-header {
|
||
position: relative;
|
||
height: 26px;
|
||
margin-bottom: 18px;
|
||
}
|
||
.header-state {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||
}
|
||
.header-idle { opacity: 1; }
|
||
.header-drag {
|
||
opacity: 0;
|
||
transform: translateY(4px);
|
||
pointer-events: none;
|
||
color: var(--text-secondary);
|
||
font-size: 16px;
|
||
}
|
||
.effort-card.live-drag .header-idle {
|
||
opacity: 0;
|
||
transform: translateY(-4px);
|
||
pointer-events: none;
|
||
}
|
||
.effort-card.live-drag .header-drag {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* ========== 「默认」打勾框:个人空间 fancy-check 缩小版 ========== */
|
||
.default-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
}
|
||
.default-label {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.default-toggle .fancy-check {
|
||
width: 20px;
|
||
height: 20px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.default-toggle .fancy-check svg {
|
||
width: 16px;
|
||
height: 16px;
|
||
overflow: visible;
|
||
}
|
||
.fancy-path {
|
||
fill: none;
|
||
stroke: var(--text-secondary);
|
||
stroke-width: 5;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
transition:
|
||
stroke-dasharray 0.5s ease,
|
||
stroke-dashoffset 0.5s ease,
|
||
stroke 0.2s ease;
|
||
stroke-dasharray: 241 9999999;
|
||
stroke-dashoffset: 0;
|
||
}
|
||
.default-toggle.checked .fancy-path {
|
||
stroke-dasharray: 70.5096664428711 9999999;
|
||
stroke-dashoffset: -262.2723388671875;
|
||
}
|
||
|
||
/* ========== 滑块区域 ========== */
|
||
.slider-zone {
|
||
position: relative;
|
||
transition: filter 0.25s ease, opacity 0.25s ease;
|
||
}
|
||
/* 默认勾选时:整体置灰,且停掉所有动画 */
|
||
.effort-card.is-default .slider-zone {
|
||
filter: grayscale(1) brightness(0.72);
|
||
opacity: 0.55;
|
||
}
|
||
.effort-card.is-default .fill-layer {
|
||
animation: none;
|
||
}
|
||
.effort-card.is-default .fill-layer::after {
|
||
animation: none;
|
||
}
|
||
|
||
.track {
|
||
position: relative;
|
||
height: 34px;
|
||
border-radius: 17px;
|
||
background: var(--track-bg);
|
||
cursor: pointer;
|
||
touch-action: none;
|
||
}
|
||
|
||
/* 填充:5 层叠放,当前档淡入、其余淡出 —— 任何颜色切换都是平滑 crossfade */
|
||
.fill-stack {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 23px;
|
||
border-radius: 17px;
|
||
overflow: hidden;
|
||
}
|
||
.fill-layer {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 100%;
|
||
opacity: 0;
|
||
transition: opacity 0.35s ease;
|
||
}
|
||
.fill-layer.on { opacity: 1; }
|
||
.fill-layer.lv-0 { background: #f5c542; }
|
||
.fill-layer.lv-1 { background: #3fd07c; }
|
||
.fill-layer.lv-2 { background: #4f8ef7; }
|
||
.fill-layer.lv-3 { background: #a55df0; }
|
||
|
||
/* xhigh:白光扫过(90deg 整列渐变无硬边;位移动画两端完全出画后回绕 → 无缝) */
|
||
.fill-layer.lv-3::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
left: -50%;
|
||
width: 45%;
|
||
background: linear-gradient(
|
||
90deg,
|
||
transparent 0%,
|
||
rgba(255, 255, 255, 0.55) 50%,
|
||
transparent 100%
|
||
);
|
||
animation: shine-sweep 1.8s linear infinite;
|
||
}
|
||
@keyframes shine-sweep {
|
||
from { left: -50%; }
|
||
to { left: 130%; }
|
||
}
|
||
|
||
/* max:彩虹流动(200% 宽 + 位移整数个循环单元 → 真无缝) */
|
||
.fill-layer.lv-4 {
|
||
width: 200%;
|
||
background: repeating-linear-gradient(
|
||
90deg,
|
||
#ff3b5c 0%,
|
||
#ff9f2e 7.15%,
|
||
#ffe93b 14.3%,
|
||
#3ddc68 21.45%,
|
||
#2fd4ff 28.6%,
|
||
#7a5cff 35.75%,
|
||
#ff4fd8 42.9%,
|
||
#ff3b5c 50%
|
||
);
|
||
animation: rainbow-flow 2.4s linear infinite;
|
||
}
|
||
/* 位移方向:色块随条从左向右流动;位移为整数个循环单元 → 真无缝 */
|
||
@keyframes rainbow-flow {
|
||
from { transform: translateX(-50%); }
|
||
to { transform: translateX(0); }
|
||
}
|
||
|
||
/* 圆钮与填充:transition 常驻 —— 点击也平滑滑动;仅真实拖动时关闭跟随 */
|
||
.knob, .fill-stack {
|
||
transition: left 0.2s ease, width 0.2s ease;
|
||
}
|
||
.effort-card.live-drag .knob,
|
||
.effort-card.live-drag .fill-stack {
|
||
transition: none;
|
||
}
|
||
|
||
/* 档位圆点 */
|
||
.dot {
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: var(--dot-off);
|
||
transition: background 0.2s ease;
|
||
pointer-events: none;
|
||
}
|
||
.dot.covered { background: var(--dot-on); }
|
||
|
||
/* 拖柄 */
|
||
.knob {
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 46px;
|
||
height: 46px;
|
||
border-radius: 50%;
|
||
background: var(--knob-bg);
|
||
transform: translate(-50%, -50%);
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||
cursor: grab;
|
||
z-index: 2;
|
||
}
|
||
.effort-card.live-drag .knob { cursor: grabbing; }
|
||
|
||
/* ========== 档位文字 ========== */
|
||
.level-labels {
|
||
position: relative;
|
||
height: 20px;
|
||
margin-top: 12px;
|
||
}
|
||
.level-label {
|
||
position: absolute;
|
||
transform: translateX(-50%);
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
transition: color 0.2s ease, font-weight 0.2s ease;
|
||
white-space: nowrap;
|
||
}
|
||
.level-label.active {
|
||
color: var(--text-primary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* demo 辅助:当前状态输出 */
|
||
.demo-state {
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
font-family: ui-monospace, monospace;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="demo-title">推理强度选择器 Demo · 拖动滑块 / 点击档位 / 勾选「默认」试试</div>
|
||
|
||
<div class="effort-card" id="card">
|
||
<!-- 顶部:非拖动 = 推理强度 + 默认;拖动中 = 更高效 / 更智能 -->
|
||
<div class="effort-header">
|
||
<div class="header-state header-idle">
|
||
<span class="header-title">推理强度</span>
|
||
<span class="default-toggle checked" id="defaultToggle">
|
||
<span class="default-label">默认</span>
|
||
<span class="fancy-check" aria-hidden="true">
|
||
<svg viewBox="0 0 64 64">
|
||
<path
|
||
d="M 0 16 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 16 L 32 48 L 64 16 V 8 A 8 8 90 0 0 56 0 H 8 A 8 8 90 0 0 0 8 V 56 A 8 8 90 0 0 8 64 H 56 A 8 8 90 0 0 64 56 V 16"
|
||
pathLength="575.0541381835938"
|
||
class="fancy-path"
|
||
></path>
|
||
</svg>
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div class="header-state header-drag">
|
||
<span>更高效</span>
|
||
<span>更智能</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 滑块 -->
|
||
<div class="slider-zone">
|
||
<div class="track" id="track">
|
||
<div class="fill-stack" id="fillStack">
|
||
<div class="fill-layer lv-0"></div>
|
||
<div class="fill-layer lv-1"></div>
|
||
<div class="fill-layer lv-2"></div>
|
||
<div class="fill-layer lv-3"></div>
|
||
<div class="fill-layer lv-4"></div>
|
||
</div>
|
||
<!-- dots 由 JS 生成 -->
|
||
<div class="knob" id="knob"></div>
|
||
</div>
|
||
<div class="level-labels" id="labels"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="demo-state" id="stateOut"></div>
|
||
|
||
<script>
|
||
(function () {
|
||
const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
||
// 轨道内两端档位的圆钮中心 inset;比圆钮半径(23)小 3px,
|
||
// 让圆钮在两端时外凸 3px,完整盖住填充条端头(避免相切处露出杂边)
|
||
const EDGE = 20;
|
||
|
||
const card = document.getElementById('card');
|
||
const track = document.getElementById('track');
|
||
const fillStack = document.getElementById('fillStack');
|
||
const layers = Array.from(fillStack.querySelectorAll('.fill-layer'));
|
||
const knob = document.getElementById('knob');
|
||
const labelsBox = document.getElementById('labels');
|
||
const defaultToggle = document.getElementById('defaultToggle');
|
||
const stateOut = document.getElementById('stateOut');
|
||
|
||
let levelIndex = 2; // 当前档位(记忆值,勾选默认时也保留)
|
||
let isDefault = true; // 是否勾选「默认」
|
||
let dragging = false; // 按下
|
||
let liveDrag = false; // 按下且已移动(真实拖动中)
|
||
let downX = 0;
|
||
|
||
const dots = [];
|
||
const labelEls = [];
|
||
|
||
// 生成 5 个档位点 + 5 个文字标签
|
||
LEVELS.forEach((name) => {
|
||
const dot = document.createElement('div');
|
||
dot.className = 'dot';
|
||
track.insertBefore(dot, knob);
|
||
dots.push(dot);
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'level-label';
|
||
label.textContent = name;
|
||
label.style.left = posPct(dots.length - 1);
|
||
labelsBox.appendChild(label);
|
||
labelEls.push(label);
|
||
});
|
||
|
||
// 档位 i 在轨道内的位置(百分比字符串,供标签/圆点用)
|
||
function posPct(i) {
|
||
const ratio = i / (LEVELS.length - 1);
|
||
return `calc(${EDGE}px + (100% - ${EDGE * 2}px) * ${ratio})`;
|
||
}
|
||
|
||
// 档位 i 在轨道内的像素 x
|
||
function posPx(i) {
|
||
const w = track.clientWidth;
|
||
return EDGE + (w - EDGE * 2) * (i / (LEVELS.length - 1));
|
||
}
|
||
|
||
// 像素 x → 最近档位
|
||
function pxToLevel(x) {
|
||
const w = track.clientWidth;
|
||
const ratio = (x - EDGE) / (w - EDGE * 2);
|
||
return Math.max(0, Math.min(LEVELS.length - 1, Math.round(ratio * (LEVELS.length - 1))));
|
||
}
|
||
|
||
function render(previewX) {
|
||
// 拖动时用连续像素位置;否则吸附到档位点(CSS transition 负责平滑)
|
||
const x = typeof previewX === 'number' ? previewX : posPx(levelIndex);
|
||
const activeLv = typeof previewX === 'number' ? pxToLevel(previewX) : levelIndex;
|
||
|
||
fillStack.style.width = x + 'px';
|
||
knob.style.left = x + 'px';
|
||
layers.forEach((layer, i) => layer.classList.toggle('on', i === activeLv));
|
||
|
||
dots.forEach((dot, i) => {
|
||
dot.style.left = posPct(i);
|
||
dot.classList.toggle('covered', posPx(i) <= x);
|
||
});
|
||
labelEls.forEach((el, i) => el.classList.toggle('active', i === activeLv));
|
||
|
||
card.classList.toggle('is-default', isDefault);
|
||
card.classList.toggle('live-drag', liveDrag);
|
||
defaultToggle.classList.toggle('checked', isDefault);
|
||
|
||
stateOut.textContent =
|
||
`level = ${isDefault ? '(默认,不传参)' : LEVELS[levelIndex]} | dragging = ${liveDrag}`;
|
||
}
|
||
|
||
// ---- 交互 ----
|
||
function onDown(e) {
|
||
dragging = true;
|
||
liveDrag = false;
|
||
downX = e.clientX;
|
||
track.setPointerCapture(e.pointerId);
|
||
// 点击(含置灰状态):解除默认并平滑滑到目标档(transition 生效)
|
||
if (isDefault) isDefault = false;
|
||
levelIndex = pxToLevel(clampX(e));
|
||
render();
|
||
}
|
||
|
||
function onMove(e) {
|
||
if (!dragging) return;
|
||
if (!liveDrag && Math.abs(e.clientX - downX) > 3) liveDrag = true;
|
||
if (liveDrag) render(clampX(e)); // 真实拖动:无 transition 连续跟随
|
||
}
|
||
|
||
function onUp(e) {
|
||
if (!dragging) return;
|
||
dragging = false;
|
||
if (liveDrag) {
|
||
// 拖动结束:吸附最近档。先用松手点的连续位置渲染(knob 不动),
|
||
// 同时移除 live-drag 恢复 transition;下一帧再吸附到档位点,
|
||
// 避免 transition 恢复与位置变化同帧导致瞬间跳变
|
||
const x = clampX(e);
|
||
levelIndex = pxToLevel(x);
|
||
liveDrag = false;
|
||
render(x);
|
||
requestAnimationFrame(() => render());
|
||
return;
|
||
}
|
||
render();
|
||
}
|
||
|
||
function clampX(e) {
|
||
const rect = track.getBoundingClientRect();
|
||
const x = e.clientX - rect.left;
|
||
return Math.max(EDGE, Math.min(rect.width - EDGE, x));
|
||
}
|
||
|
||
track.addEventListener('pointerdown', onDown);
|
||
track.addEventListener('pointermove', onMove);
|
||
track.addEventListener('pointerup', onUp);
|
||
track.addEventListener('pointercancel', onUp);
|
||
|
||
// 勾选/取消「默认」
|
||
defaultToggle.addEventListener('click', () => {
|
||
isDefault = !isDefault;
|
||
render();
|
||
});
|
||
|
||
window.addEventListener('resize', () => render());
|
||
|
||
render();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|