feat: 支持聊天多版本答案切换

- 为管理端、公共聊天和用户中心补充回答变体查询与切换能力

- 支持基于指定轮次重新生成并同步前后端多版本状态

- 保留 application.yml 与本地截图文件为未提交状态
This commit is contained in:
2026-05-14 21:23:20 +08:00
parent da58077d59
commit 1a6ea64e80
23 changed files with 2625 additions and 122 deletions

View File

@@ -0,0 +1,187 @@
type VariantRecord = {
selectedVariantIndex?: number | string;
variantIndex?: number | string;
};
interface ChatVariantSwitchControllerOptions<TRecord extends VariantRecord, TItem> {
mapRecords: (records: TRecord[]) => TItem[];
onError?: (error: unknown) => void;
onStateChange?: () => void;
replaceRound: (items: TItem[], roundId: string, nextItems: TItem[]) => void;
}
interface EnsureVariantsOptions<TRecord extends VariantRecord> {
fetchVariants: () => Promise<TRecord[]>;
roundId: number | string;
sessionId: number | string;
}
interface SwitchVariantOptions<TRecord extends VariantRecord, TItem>
extends EnsureVariantsOptions<TRecord> {
items: TItem[];
onLocalSwitch?: (record: TRecord) => void;
persistVariant: () => Promise<TRecord | void>;
targetVariantIndex: number;
}
function variantCacheKey(sessionId: number | string, roundId: number | string) {
return `${String(sessionId)}:${String(roundId)}`;
}
function normalizeVariantIndex(value: unknown) {
const parsed = Number.parseInt(String(value || ''), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function markVariantSelected<TRecord extends VariantRecord>(
record: TRecord,
selectedVariantIndex: number,
): TRecord {
return {
...record,
selectedVariantIndex,
};
}
function syncCachedSelection<TRecord extends VariantRecord>(
records: TRecord[],
selectedVariantIndex: number,
selectedRecord?: TRecord,
) {
return records.map((record) => {
const isSelected =
selectedRecord &&
normalizeVariantIndex(record.variantIndex) ===
normalizeVariantIndex(selectedRecord.variantIndex);
return markVariantSelected(
isSelected ? { ...record, ...selectedRecord } : record,
selectedVariantIndex,
);
});
}
export function createChatVariantSwitchController<
TRecord extends VariantRecord,
TItem,
>(options: ChatVariantSwitchControllerOptions<TRecord, TItem>) {
const cache = new Map<string, TRecord[]>();
const fetchTasks = new Map<string, Promise<TRecord[]>>();
const switchingKeys = new Set<string>();
function notifyStateChange() {
options.onStateChange?.();
}
async function ensureVariants(params: EnsureVariantsOptions<TRecord>) {
const key = variantCacheKey(params.sessionId, params.roundId);
const cached = cache.get(key);
if (cached) {
return cached;
}
const existingTask = fetchTasks.get(key);
if (existingTask) {
return existingTask;
}
const task = params
.fetchVariants()
.then((records) => {
cache.set(key, records);
return records;
})
.finally(() => {
fetchTasks.delete(key);
});
fetchTasks.set(key, task);
return task;
}
function prefetchVariants(params: EnsureVariantsOptions<TRecord>) {
void ensureVariants(params).catch(() => {
// 预取失败不打断当前页面,用户点击时仍会再次拉取。
});
}
function hasCachedVariant(
sessionId: number | string,
roundId: number | string,
variantIndex: number,
) {
const records = cache.get(variantCacheKey(sessionId, roundId));
return Boolean(
records?.some(
(record) => normalizeVariantIndex(record.variantIndex) === variantIndex,
),
);
}
function isSwitching(sessionId?: number | string, roundId?: number | string) {
if (!sessionId || !roundId) {
return false;
}
return switchingKeys.has(variantCacheKey(sessionId, roundId));
}
async function switchVariant(params: SwitchVariantOptions<TRecord, TItem>) {
const key = variantCacheKey(params.sessionId, params.roundId);
if (switchingKeys.has(key)) {
return null;
}
switchingKeys.add(key);
notifyStateChange();
const snapshot = [...params.items];
try {
const records = await ensureVariants(params);
const target = records.find(
(record) =>
normalizeVariantIndex(record.variantIndex) === params.targetVariantIndex,
);
if (!target) {
throw new Error('目标答案版本不存在');
}
const localTarget = markVariantSelected(target, params.targetVariantIndex);
const nextItems = options.mapRecords([localTarget]);
if (nextItems.length === 0) {
throw new Error('目标答案版本渲染失败');
}
options.replaceRound(
params.items,
String(params.roundId),
nextItems,
);
params.onLocalSwitch?.(localTarget);
const persistedRecord = await params.persistVariant();
const selectedRecord = markVariantSelected(
persistedRecord || localTarget,
params.targetVariantIndex,
);
cache.set(
key,
syncCachedSelection(records, params.targetVariantIndex, selectedRecord),
);
return selectedRecord;
} catch (error) {
params.items.splice(0, params.items.length, ...snapshot);
options.onError?.(error);
return null;
} finally {
switchingKeys.delete(key);
notifyStateChange();
}
}
function cacheVariants(
sessionId: number | string,
roundId: number | string,
records: TRecord[],
) {
cache.set(variantCacheKey(sessionId, roundId), records);
}
return {
cacheVariants,
hasCachedVariant,
isSwitching,
prefetchVariants,
switchVariant,
};
}