Compare commits

...

2 Commits

Author SHA1 Message Date
3650e0eda8 Update story summary UI styles 2026-02-10 15:50:16 +08:00
1dfbc74401 Remove unused state-recall module 2026-02-10 15:48:35 +08:00
3 changed files with 696 additions and 192 deletions

View File

@@ -2903,3 +2903,486 @@ h1 span {
#recall-log-content .metric-good { #recall-log-content .metric-good {
color: #22c55e; color: #22c55e;
} }
/* ═══════════════════════════════════════════════════════════════════════════
Guide Tab (使用说明)
═══════════════════════════════════════════════════════════════════════════ */
.guide-container {
font-size: .875rem;
line-height: 1.7;
color: var(--txt2);
}
/* Section */
.guide-section {
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid var(--bdr2);
}
.guide-section-last,
.guide-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
/* Title */
.guide-title {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
font-size: 1rem;
font-weight: 600;
color: var(--txt);
}
.guide-num {
width: 26px;
height: 26px;
background: var(--acc);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: .8125rem;
flex-shrink: 0;
}
/* Text */
.guide-text {
margin-bottom: 8px;
color: var(--txt2);
}
.guide-text:last-child {
margin-bottom: 0;
}
.guide-text a {
color: var(--hl);
text-decoration: none;
}
.guide-text a:hover {
text-decoration: underline;
}
/* Steps */
.guide-steps {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 4px;
}
.guide-step {
display: flex;
gap: 12px;
align-items: flex-start;
}
.guide-step-num {
width: 22px;
height: 22px;
background: var(--hl);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: .75rem;
flex-shrink: 0;
margin-top: 2px;
}
.guide-step-body {
flex: 1;
min-width: 0;
}
.guide-step-title {
font-weight: 600;
color: var(--txt);
margin-bottom: 2px;
font-size: .875rem;
}
.guide-step-desc {
color: var(--txt2);
font-size: .8125rem;
line-height: 1.6;
}
.guide-step-desc a {
color: var(--hl);
text-decoration: none;
}
.guide-step-desc a:hover {
text-decoration: underline;
}
.guide-tag {
display: inline-block;
padding: 1px 8px;
background: var(--hl-soft);
color: var(--hl);
border-radius: 10px;
font-size: .6875rem;
font-weight: 500;
vertical-align: middle;
margin-left: 4px;
}
/* Card List */
.guide-card-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 4px;
}
.guide-card {
padding: 14px 16px;
background: var(--bg3);
border: 1px solid var(--bdr2);
border-radius: 6px;
transition: border-color .2s;
}
.guide-card:hover {
border-color: var(--bdr);
}
.guide-card-title {
font-size: .8125rem;
font-weight: 600;
color: var(--txt);
margin-bottom: 6px;
}
.guide-card-desc {
font-size: .75rem;
color: var(--txt3);
line-height: 1.6;
}
/* Highlight */
.guide-highlight {
padding: 16px 20px;
background: linear-gradient(135deg, rgba(216, 122, 122, .06), rgba(216, 122, 122, .02));
border: 1px solid rgba(216, 122, 122, .2);
border-radius: 8px;
margin-top: 4px;
}
.guide-highlight-title {
font-weight: 600;
color: var(--txt);
margin-bottom: 8px;
font-size: .875rem;
}
/* List */
.guide-list {
margin: 8px 0;
padding-left: 20px;
color: var(--txt2);
}
.guide-list li {
margin-bottom: 6px;
line-height: 1.6;
}
.guide-list li:last-child {
margin-bottom: 0;
}
.guide-list li::marker {
color: var(--hl);
}
.guide-list a {
color: var(--hl);
text-decoration: none;
}
.guide-list a:hover {
text-decoration: underline;
}
.guide-list code {
background: var(--bg3);
padding: 1px 5px;
border-radius: 3px;
font-size: .75rem;
font-family: 'SF Mono', Monaco, Consolas, monospace;
color: var(--txt);
}
.guide-list-inner {
margin-top: 6px;
margin-bottom: 4px;
padding-left: 18px;
}
.guide-list-inner li {
margin-bottom: 3px;
font-size: .8125rem;
color: var(--txt3);
}
.guide-list-inner li::marker {
color: var(--txt3);
}
/* Tip */
.guide-tip {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 16px;
background: var(--bg3);
border: 1px solid var(--bdr2);
border-left: 3px solid var(--hl);
border-radius: 0 6px 6px 0;
margin-top: 12px;
}
.guide-tip-icon {
font-size: 1rem;
flex-shrink: 0;
line-height: 1.5;
}
.guide-tip-text {
font-size: .8125rem;
color: var(--txt2);
line-height: 1.6;
flex: 1;
}
/* Tips List */
.guide-tips-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.guide-tips-list .guide-tip {
margin-top: 0;
}
/* FAQ */
.guide-faq-list {
display: flex;
flex-direction: column;
gap: 0;
margin-top: 4px;
}
.guide-faq-item {
padding: 14px 0;
border-bottom: 1px solid var(--bdr2);
}
.guide-faq-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.guide-faq-item:first-child {
padding-top: 0;
}
.guide-faq-q {
font-weight: 600;
color: var(--txt);
font-size: .8125rem;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.guide-faq-q::before {
content: 'Q';
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--acc);
color: #fff;
border-radius: 3px;
font-size: .625rem;
font-weight: 700;
flex-shrink: 0;
}
.guide-faq-a {
font-size: .8125rem;
color: var(--txt2);
line-height: 1.6;
padding-left: 24px;
}
/* ═══════════════════════════════════════════════════════════════════════════
Guide Tab - Responsive
═══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 768px) {
.guide-container {
font-size: .8125rem;
}
.guide-title {
font-size: .9375rem;
}
.guide-num {
width: 24px;
height: 24px;
font-size: .75rem;
}
.guide-section {
margin-bottom: 20px;
padding-bottom: 18px;
}
.guide-card-list {
grid-template-columns: 1fr;
gap: 8px;
}
.guide-card {
padding: 12px 14px;
}
.guide-step {
gap: 10px;
}
.guide-step-num {
width: 20px;
height: 20px;
font-size: .6875rem;
}
.guide-highlight {
padding: 14px 16px;
}
.guide-tip {
padding: 10px 12px;
}
.guide-faq-a {
padding-left: 0;
}
.guide-faq-q::before {
width: 16px;
height: 16px;
font-size: .5625rem;
}
}
@media (max-width: 480px) {
.guide-container {
font-size: .75rem;
}
.guide-title {
font-size: .875rem;
gap: 8px;
}
.guide-num {
width: 22px;
height: 22px;
font-size: .6875rem;
}
.guide-section {
margin-bottom: 16px;
padding-bottom: 14px;
}
.guide-steps {
gap: 12px;
}
.guide-step-title {
font-size: .8125rem;
}
.guide-step-desc {
font-size: .75rem;
}
.guide-card-title {
font-size: .75rem;
}
.guide-card-desc {
font-size: .6875rem;
}
.guide-highlight {
padding: 12px 14px;
}
.guide-highlight-title {
font-size: .8125rem;
}
.guide-list {
padding-left: 16px;
font-size: .75rem;
}
.guide-list-inner li {
font-size: .75rem;
}
.guide-tip {
padding: 8px 10px;
gap: 8px;
}
.guide-tip-icon {
font-size: .875rem;
}
.guide-tip-text {
font-size: .75rem;
}
.guide-faq-q {
font-size: .75rem;
}
.guide-faq-a {
font-size: .75rem;
}
.guide-faq-item {
padding: 10px 0;
}
}
@media (hover: none) and (pointer: coarse) {
.guide-card:hover {
border-color: var(--bdr2);
}
}

View File

@@ -164,6 +164,7 @@
<div class="settings-tab active" data-tab="tab-summary">总结设置</div> <div class="settings-tab active" data-tab="tab-summary">总结设置</div>
<div class="settings-tab" data-tab="tab-vector">向量设置</div> <div class="settings-tab" data-tab="tab-vector">向量设置</div>
<div class="settings-tab" data-tab="tab-debug">调试</div> <div class="settings-tab" data-tab="tab-debug">调试</div>
<div class="settings-tab" data-tab="tab-guide">使用说明</div>
</div> </div>
<button class="modal-close" id="settings-close"> <button class="modal-close" id="settings-close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -364,7 +365,7 @@
<input type="password" id="vector-api-key" placeholder="sk-xxx"> <input type="password" id="vector-api-key" placeholder="sk-xxx">
<div class="settings-hint"> <div class="settings-hint">
💡 <a href="https://siliconflow.cn" target="_blank">硅基流动</a> 💡 <a href="https://siliconflow.cn" target="_blank">硅基流动</a>
内置使用免费模型bge-m3、Qwen3-8B,注册认证拿 Key 即可 内置使用的模型完全免费,注册拿 Key 即可。建议完成实名认证以获得更高并发
</div> </div>
</div> </div>
</div> </div>
@@ -408,7 +409,7 @@
<span class="anchor-icon">📌</span> <span class="anchor-icon">📌</span>
<span>记忆锚点</span> <span>记忆锚点</span>
</div> </div>
<div class="anchor-hint">从对话中提取叙事锚点(情绪、地点、动作、揭示等)</div> <div class="anchor-hint">从对话中提取叙事锚点(情绪、地点、动作、揭示等),首次提取较慢(约每百楼 2 分钟)</div>
</div> </div>
<div class="anchor-stats" id="anchor-stats"> <div class="anchor-stats" id="anchor-stats">
@@ -503,7 +504,7 @@
</div> </div>
<div class="settings-hint" style="margin-top:8px"> <div class="settings-hint" style="margin-top:8px">
向量化现有 L0/L1/L2 数据(首次可能需要 1-2 分钟) 向量化现有数据,生成速度很快
</div> </div>
<!-- 导入导出 --> <!-- 导入导出 -->
@@ -532,6 +533,215 @@
</div> </div>
<pre id="recall-log-content" class="debug-log-viewer"></pre> <pre id="recall-log-content" class="debug-log-viewer"></pre>
</div> </div>
<!-- Tab 4: Guide -->
<div class="tab-pane" id="tab-guide">
<div class="guide-container">
<!-- ❶ 这是什么 -->
<div class="guide-section">
<div class="guide-title">
<span class="guide-num">1</span>
<span>这是什么</span>
</div>
<div class="guide-text">
AI 聊久了会忘记前面发生过的事,角色开始前后矛盾、丢失记忆。
</div>
<div class="guide-text">
<strong>剧情总结</strong>帮 AI 记住你们的故事。开启后AI 回复时能自然地引用之前的情节,记住角色关系,不再前后矛盾。
</div>
</div>
<!-- ❷ 三步上手 -->
<div class="guide-section">
<div class="guide-title">
<span class="guide-num">2</span>
<span>三步上手</span>
</div>
<div class="guide-steps">
<div class="guide-step">
<div class="guide-step-num">1</div>
<div class="guide-step-body">
<div class="guide-step-title">正常聊天</div>
<div class="guide-step-desc">先攒够 20 楼左右的对话内容。</div>
</div>
</div>
<div class="guide-step">
<div class="guide-step-num">2</div>
<div class="guide-step-body">
<div class="guide-step-title">点击「总结」</div>
<div class="guide-step-desc">
打开面板,点右上角的「总结」按钮。系统会自动提炼剧情要点。
之后可以在「总结设置」里开启自动总结,就不用每次手动了。
</div>
</div>
</div>
<div class="guide-step">
<div class="guide-step-num">3</div>
<div class="guide-step-body">
<div class="guide-step-title">开启智能记忆 <span class="guide-tag">推荐</span></div>
<div class="guide-step-desc">
在「向量设置」Tab 中勾选启用,填入
<a href="https://siliconflow.cn" target="_blank">硅基流动</a>
的 API Key然后依次点击「生成锚点」→「生成向量」。之后全自动每条新消息都会自动处理。
</div>
</div>
</div>
</div>
</div>
<!-- ❸ 基础功能 -->
<div class="guide-section">
<div class="guide-title">
<span class="guide-num">3</span>
<span>基础功能:剧情总结</span>
</div>
<div class="guide-card-list">
<div class="guide-card">
<div class="guide-card-title">📝 总结</div>
<div class="guide-card-desc">
点击按钮提炼已有对话的剧情要点。只处理新增的楼层,不会重复已有内容。
</div>
</div>
<div class="guide-card">
<div class="guide-card-title">⚡ 自动总结</div>
<div class="guide-card-desc">
在「总结设置」里开启后,每隔一定楼数自动提炼,不用手动操作。
</div>
</div>
<div class="guide-card">
<div class="guide-card-title">👁️ 隐藏已总结楼层</div>
<div class="guide-card-desc">
已经被总结过的旧楼层不再发送给 AI节省 token 预算,让 AI 把注意力集中在新内容和记忆摘要上,回复质量更高。
</div>
</div>
<div class="guide-card">
<div class="guide-card-title">✏️ 手动编辑</div>
<div class="guide-card-desc">
面板里的关键词、事件时间线、人物关系、角色弧光、世界状态都可以点「编辑」直接修改,修改立即生效。
</div>
</div>
</div>
</div>
<!-- ❹ 核心功能 -->
<div class="guide-section">
<div class="guide-title">
<span class="guide-num">4</span>
<span>核心功能:智能记忆</span>
</div>
<div class="guide-highlight">
<div class="guide-highlight-title">为什么需要?</div>
<div class="guide-text">
光靠总结摘要AI 只能看到「发生过什么」,但丢失了原文里的细节、语气和场景氛围。智能记忆让 AI
能在回复前自动从所有历史对话中找到和当前话题最相关的记忆片段,连同原文细节一起回忆起来。
</div>
</div>
<div class="guide-text" style="margin-top: 16px"><strong>开启后你会感受到:</strong></div>
<ul class="guide-list">
<li>角色在关键时刻引用了很久以前的约定</li>
<li>提到某个地点时AI 记得那里曾经发生过什么</li>
<li>人物关系的微妙变化被一直记住</li>
<li>伏笔在很久之后被自然地呼应</li>
<li>不管故事写了多长AI 都能精准回忆起相关的情节</li>
</ul>
<div class="guide-text" style="margin-top: 16px"><strong>需要什么:</strong></div>
<ul class="guide-list">
<li>
一个 <a href="https://siliconflow.cn" target="_blank">硅基流动</a> 的 API
Key。我们使用的三个模型全部<strong>完全免费</strong>,不限额度:
<ul class="guide-list-inner">
<li><code>bge-m3</code> — 记忆向量化</li>
<li><code>bge-reranker-v2-m3</code> — 记忆精排</li>
<li><code>Qwen3-8B</code> — 记忆锚点提取</li>
</ul>
建议完成实名认证以获得更高并发,记忆锚点提取速度会更快。
</li>
<li>
首次使用:点「生成锚点」→「生成向量」。锚点提取需要一些时间(约每百楼 2 分钟),向量生成很快。
</li>
<li>之后全自动,每条新消息都会自动处理。</li>
</ul>
<div class="guide-tip">
<div class="guide-tip-icon">💡</div>
<div class="guide-tip-text">
<strong>不开智能记忆也能用。</strong>总结功能照常工作AI 能看到剧情摘要,但看不到原文细节。长篇故事强烈推荐开启,效果非常明显。
</div>
</div>
</div>
<!-- ❺ 常见问题 -->
<div class="guide-section">
<div class="guide-title">
<span class="guide-num">5</span>
<span>常见问题</span>
</div>
<div class="guide-faq-list">
<div class="guide-faq-item">
<div class="guide-faq-q">总结用的是哪个 API</div>
<div class="guide-faq-a">默认用你酒馆当前连接的 API。也可以在「总结设置」里单独指定一个不同的 API 和模型。</div>
</div>
<div class="guide-faq-item">
<div class="guide-faq-q">删消息 / Swipe 会出问题吗?</div>
<div class="guide-faq-a">不会。系统会自动同步所有数据,不需要手动处理。</div>
</div>
<div class="guide-faq-item">
<div class="guide-faq-q">换了角色 / 聊天要重新操作吗?</div>
<div class="guide-faq-a">总结和向量都是按聊天独立保存的,切换聊天会自动加载对应的数据。</div>
</div>
<div class="guide-faq-item">
<div class="guide-faq-q">怎么知道 AI 有没有用上记忆?</div>
<div class="guide-faq-a">在「调试」Tab 可以看召回日志,会显示本次回忆了哪些内容。</div>
</div>
<div class="guide-faq-item">
<div class="guide-faq-q">总结结果不准确怎么办?</div>
<div class="guide-faq-a">面板里每个区块右上角都有「编辑」按钮,可以直接修改,修改立即生效。</div>
</div>
<div class="guide-faq-item">
<div class="guide-faq-q">会不会影响回复速度?</div>
<div class="guide-faq-a">每次回复前的记忆召回通常在 1-3 秒内完成。首次生成锚点时较慢(约每百楼 2
分钟),但只需做一次,之后新消息逐条自动处理,感知不到延迟。</div>
</div>
<div class="guide-faq-item">
<div class="guide-faq-q">硅基的 Key 要花钱吗?</div>
<div class="guide-faq-a">
不花钱。我们使用的三个模型bge-m3、bge-reranker-v2-m3、Qwen3-8B本身就是完全免费的模型不存在额度限制永久免费使用。
</div>
</div>
</div>
</div>
<!-- ❻ 小贴士 -->
<div class="guide-section guide-section-last">
<div class="guide-title">
<span class="guide-num">6</span>
<span>小贴士</span>
</div>
<div class="guide-tips-list">
<div class="guide-tip">
<div class="guide-tip-icon">🎯</div>
<div class="guide-tip-text">首次总结建议先手动点一次,看看效果再决定是否开启自动总结。</div>
</div>
<div class="guide-tip">
<div class="guide-tip-icon">📖</div>
<div class="guide-tip-text">长篇故事强烈推荐开启智能记忆,故事越长效果越明显。</div>
</div>
<div class="guide-tip">
<div class="guide-tip-icon">💾</div>
<div class="guide-tip-text">向量数据支持导出备份,换设备时可以导入恢复,不用重新生成。</div>
</div>
<div class="guide-tip">
<div class="guide-tip-icon"></div>
<div class="guide-tip-text">锚点提取是最耗时的步骤。硅基完成实名认证后并发更高,批量处理速度会明显提升。</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button class="btn" id="settings-cancel">取消</button> <button class="btn" id="settings-cancel">取消</button>

View File

@@ -1,189 +0,0 @@
// ═══════════════════════════════════════════════════════════════════════════
// Story Summary - State Recall (L0)
// L0 语义锚点召回 + floor bonus + 虚拟 chunk 转换
// ═══════════════════════════════════════════════════════════════════════════
import { getContext } from '../../../../../../../extensions.js';
import { getAllStateVectors, getStateAtoms } from '../storage/state-store.js';
import { getMeta } from '../storage/chunk-store.js';
import { getEngineFingerprint } from '../utils/embedder.js';
import { xbLog } from '../../../../core/debug-core.js';
const MODULE_ID = 'state-recall';
const CONFIG = {
MAX_RESULTS: 20,
MIN_SIMILARITY: 0.55,
};
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
function cosineSimilarity(a, b) {
if (!a?.length || !b?.length || a.length !== b.length) return 0;
let dot = 0, nA = 0, nB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
nA += a[i] * a[i];
nB += b[i] * b[i];
}
return nA && nB ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
}
// ═══════════════════════════════════════════════════════════════════════════
// L0 向量检索
// ═══════════════════════════════════════════════════════════════════════════
/**
* 检索与 query 相似的 StateAtoms
* @returns {Array<{atom, similarity}>}
*/
export async function searchStateAtoms(queryVector, vectorConfig) {
const { chatId } = getContext();
if (!chatId || !queryVector?.length) return [];
// 检查 fingerprint
const meta = await getMeta(chatId);
const fp = getEngineFingerprint(vectorConfig);
if (meta.fingerprint && meta.fingerprint !== fp) {
xbLog.warn(MODULE_ID, 'fingerprint 不匹配,跳过 L0 召回');
return [];
}
// 获取向量
const stateVectors = await getAllStateVectors(chatId);
if (!stateVectors.length) return [];
// 获取 atoms用于关联 semantic 等字段)
const atoms = getStateAtoms();
const atomMap = new Map(atoms.map(a => [a.atomId, a]));
// 计算相似度
const scored = stateVectors
.map(sv => {
const atom = atomMap.get(sv.atomId);
if (!atom) return null;
return {
atomId: sv.atomId,
floor: sv.floor,
similarity: cosineSimilarity(queryVector, sv.vector),
atom,
};
})
.filter(Boolean)
.filter(s => s.similarity >= CONFIG.MIN_SIMILARITY)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, CONFIG.MAX_RESULTS);
return scored;
}
// ═══════════════════════════════════════════════════════════════════════════
// Floor Bonus 构建
// ═══════════════════════════════════════════════════════════════════════════
/**
* 构建 L0 相关楼层的加权映射
* @returns {Map<number, number>}
*/
export function buildL0FloorBonus(l0Results, bonusFactor = 0.10) {
const floorBonus = new Map();
for (const r of l0Results || []) {
// 每个楼层只加一次,取最高相似度对应的 bonus
// 简化处理:统一加 bonusFactor不区分相似度高低
if (!floorBonus.has(r.floor)) {
floorBonus.set(r.floor, bonusFactor);
}
}
return floorBonus;
}
// ═══════════════════════════════════════════════════════════════════════════
// 虚拟 Chunk 转换
// ═══════════════════════════════════════════════════════════════════════════
/**
* 将 L0 结果转换为虚拟 chunk 格式
* 用于和 L1 chunks 统一处理
*/
export function stateToVirtualChunks(l0Results) {
return (l0Results || []).map(r => ({
chunkId: `state-${r.atomId}`,
floor: r.floor,
chunkIdx: -1, // 负值,排序时排在 L1 前面
speaker: '📌', // 固定标记
isUser: false,
text: r.atom.semantic,
textHash: null,
similarity: r.similarity,
isL0: true, // 标记字段
// 保留原始 atom 信息
_atom: r.atom,
}));
}
// ═══════════════════════════════════════════════════════════════════════════
// 每楼层稀疏去重
// ═══════════════════════════════════════════════════════════════════════════
/**
* 合并 L0 和 L1 chunks
* @param {Array} l0Chunks - L0 虚拟 chunks带 similarity
* @param {Array} l1Chunks - L1 真实 chunks无 similarity
* @param {number} limit - 每楼层上限
* @returns {Array} 合并后的 chunks
*/
export function mergeAndSparsify(l0Chunks, l1Chunks, limit = 2) {
// 构建 L0 楼层 → 最高 similarity 映射
const floorSimilarity = new Map();
for (const c of (l0Chunks || [])) {
const existing = floorSimilarity.get(c.floor) || 0;
if ((c.similarity || 0) > existing) {
floorSimilarity.set(c.floor, c.similarity || 0);
}
}
// L1 继承所属楼层的 L0 similarity
const l1WithScore = (l1Chunks || []).map(c => ({
...c,
similarity: floorSimilarity.get(c.floor) || 0.5,
}));
// 合并并按相似度排序
const all = [...(l0Chunks || []), ...l1WithScore]
.sort((a, b) => {
// 相似度优先
const simDiff = (b.similarity || 0) - (a.similarity || 0);
if (Math.abs(simDiff) > 0.01) return simDiff;
// 同楼层L0 优先于 L1
if (a.floor === b.floor) {
if (a.isL0 && !b.isL0) return -1;
if (!a.isL0 && b.isL0) return 1;
}
// 按楼层升序
return a.floor - b.floor;
});
// 每楼层稀疏去重
const byFloor = new Map();
for (const c of all) {
const arr = byFloor.get(c.floor) || [];
if (arr.length < limit) {
arr.push(c);
byFloor.set(c.floor, arr);
}
}
// 扁平化并保持排序
return Array.from(byFloor.values())
.flat()
.sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
}