246 lines
8.2 KiB
TypeScript
246 lines
8.2 KiB
TypeScript
/**
|
||
* 统一锚点定位协议引擎 (Anchor Navigation Protocol Engine)
|
||
* 遵循技术规范 7.1:
|
||
* 1. 快路径: 锚点已在当前窗口 DOM 中(或可由虚拟化器定位),瞬时居中对焦并触发 1.8s 发光脉冲
|
||
* 2. 慢路径: 锚点不在窗口中,调用上下文拉取目标日分片合并后对焦
|
||
* 3. 深链 Hash: #/s/{sessionId}/m/{messageId} 解析与状态同步
|
||
* 4. (P3) 虚拟滚动兼容: 优先通过 VirtualScrollHandle.scrollToMessageId 定位
|
||
*/
|
||
|
||
import { ChatMessage } from '../types/workbench';
|
||
|
||
/** P3: 虚拟化滚动器句柄接口 */
|
||
export interface VirtualScrollHandle {
|
||
scrollToMessageId: (messageId: number) => void;
|
||
}
|
||
|
||
/**
|
||
* 方案 A (交互轮次归一化):
|
||
* 若目标消息是 assistant 回复,统一向上溯源定位至该轮触发提问的 user 消息 ID。
|
||
* 配合“起始位置居中”,视口中上方展现用户提问,正下方自然展开助手回答,因果一目了然。
|
||
*/
|
||
export function resolveTurnAnchorId(
|
||
targetMessageId: number,
|
||
allMessages?: ChatMessage[]
|
||
): number {
|
||
if (!allMessages || allMessages.length === 0 || !targetMessageId) {
|
||
return targetMessageId;
|
||
}
|
||
|
||
const idx = allMessages.findIndex(
|
||
(m) => (m.serverId ?? 0) === targetMessageId || m.id === String(targetMessageId)
|
||
);
|
||
if (idx === -1) return targetMessageId;
|
||
|
||
const targetMsg = allMessages[idx];
|
||
// 方案 A 轮次归一化:仅当自身是 assistant 回复时,才向上溯源到该轮提问的 user 消息
|
||
if (targetMsg.role !== 'assistant') {
|
||
return targetMessageId;
|
||
}
|
||
|
||
// 若自身是 assistant,优先通过显式 sourceUserMessageId 寻找提问
|
||
if (targetMsg.sourceUserMessageId) {
|
||
const userMsg = allMessages.find((m) => m.id === targetMsg.sourceUserMessageId);
|
||
if (userMsg && userMsg.serverId) {
|
||
return userMsg.serverId;
|
||
}
|
||
}
|
||
|
||
// 沿时间线回溯查找本轮前序最近的一条 user 提问
|
||
for (let i = idx - 1; i >= 0; i--) {
|
||
if (allMessages[i].role === 'user') {
|
||
const raw = allMessages[i].serverId ?? allMessages[i].id;
|
||
const numId = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
|
||
if (typeof numId === 'number' && !isNaN(numId)) {
|
||
return numId;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 孤立系统消息或无前序提问,回退自身
|
||
return targetMessageId;
|
||
}
|
||
|
||
// 全局注册的虚拟化句柄(MessageStream mount 时注入,unmount 时清除)
|
||
let _virtualHandle: VirtualScrollHandle | null = null;
|
||
|
||
/** P3: MessageStream 挂载时调用,注册虚拟化滚动句柄 */
|
||
export function registerVirtualScrollHandle(handle: VirtualScrollHandle): void {
|
||
_virtualHandle = handle;
|
||
}
|
||
|
||
/** P3: MessageStream 卸载时调用,清除句柄 */
|
||
export function unregisterVirtualScrollHandle(): void {
|
||
_virtualHandle = null;
|
||
}
|
||
|
||
export interface AnchorTarget {
|
||
messageId: number;
|
||
sessionId: string;
|
||
highlightMs?: number;
|
||
}
|
||
|
||
export interface ChatWindowNavContext {
|
||
sessionId: string;
|
||
loadDayForAnchor?: (messageId: number) => Promise<boolean>;
|
||
}
|
||
|
||
/**
|
||
* 格式化锚点 URL Hash
|
||
*/
|
||
export function formatAnchorHash(sessionId: string, messageId: number): string {
|
||
return `#/s/${sessionId}/m/${messageId}`;
|
||
}
|
||
|
||
/**
|
||
* 解析锚点 URL Hash
|
||
* 支持标准 #/s/{sessionId}/m/{messageId} 格式
|
||
*/
|
||
export function parseAnchorHash(hash: string): AnchorTarget | null {
|
||
if (!hash) return null;
|
||
const cleaned = hash.startsWith('#') ? hash.substring(1) : hash;
|
||
const match = cleaned.match(/^\/s\/([^/]+)\/m\/(\d+)$/);
|
||
if (!match) return null;
|
||
|
||
const sessionId = match[1];
|
||
const messageId = parseInt(match[2], 10);
|
||
if (isNaN(messageId) || messageId <= 0) return null;
|
||
|
||
return { sessionId, messageId };
|
||
}
|
||
|
||
export type SubagentsTarget = {
|
||
sessionId: string;
|
||
runId: string;
|
||
roleId?: string;
|
||
};
|
||
|
||
/**
|
||
* 小队过程子页面深链:#/s/{sessionId}/run/{runId}/agents[/{roleId}]
|
||
* 与消息锚点 #/s/{sessionId}/m/{messageId} 互斥。
|
||
*/
|
||
export function parseSubagentsHash(hash: string): SubagentsTarget | null {
|
||
if (!hash) return null;
|
||
const cleaned = hash.startsWith('#') ? hash.substring(1) : hash;
|
||
const match = cleaned.match(/^\/s\/([^/]+)\/run\/([^/]+)\/agents(?:\/([^/]+))?$/);
|
||
if (!match) return null;
|
||
const sessionId = decodeURIComponent(match[1]);
|
||
const runId = decodeURIComponent(match[2]);
|
||
if (!sessionId || !runId) return null;
|
||
const roleId = match[3] ? decodeURIComponent(match[3]) : undefined;
|
||
return { sessionId, runId, roleId };
|
||
}
|
||
|
||
export function subagentsHash(target: SubagentsTarget): string {
|
||
const base = `#/s/${encodeURIComponent(target.sessionId)}/run/${encodeURIComponent(target.runId)}/agents`;
|
||
return target.roleId ? `${base}/${encodeURIComponent(target.roleId)}` : base;
|
||
}
|
||
|
||
/**
|
||
* 触发 DOM 元素的锚点高亮脉冲
|
||
*/
|
||
export function triggerAnchorHighlight(el: HTMLElement, highlightMs = 1800): void {
|
||
el.classList.remove('chat-anchor-pulse');
|
||
// 强制触发 reflow 使动画重新生效
|
||
void el.offsetWidth;
|
||
el.classList.add('chat-anchor-pulse');
|
||
|
||
setTimeout(() => {
|
||
el.classList.remove('chat-anchor-pulse');
|
||
}, highlightMs);
|
||
}
|
||
|
||
/**
|
||
* 将窗口导航定位到指定消息锚点
|
||
*
|
||
* P3 兼容:优先通过虚拟化句柄定位,再等待 DOM 挂载后触发高亮脉冲。
|
||
*/
|
||
export async function navigateToAnchor(
|
||
target: AnchorTarget,
|
||
ctx?: ChatWindowNavContext
|
||
): Promise<boolean> {
|
||
const elementId = `chat-anchor-${target.messageId}`;
|
||
const highlightMs = target.highlightMs ?? 1800;
|
||
|
||
const tryHighlight = (id: string): boolean => {
|
||
const el = typeof document !== 'undefined' ? document.getElementById(id) : null;
|
||
if (el) {
|
||
triggerAnchorHighlight(el, highlightMs);
|
||
if (typeof window !== 'undefined' && target.sessionId) {
|
||
window.location.hash = formatAnchorHash(target.sessionId, target.messageId);
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// 1. 快路径: 优先通过虚拟化句柄定位(P3 路径)
|
||
const vHandleFast = _virtualHandle;
|
||
if (vHandleFast) {
|
||
vHandleFast.scrollToMessageId(target.messageId);
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
const ok = tryHighlight(elementId);
|
||
if (ok) {
|
||
if (typeof window !== 'undefined' && target.sessionId) {
|
||
window.location.hash = formatAnchorHash(target.sessionId, target.messageId);
|
||
}
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// 2. 快路径(降级): 目标已在当前 DOM 中(非虚拟化场景)
|
||
let el = typeof document !== 'undefined' ? document.getElementById(elementId) : null;
|
||
if (el) {
|
||
const container = el.closest?.('[style*="overflow"]') || el.parentElement;
|
||
if (container && typeof container.getBoundingClientRect === 'function' && typeof el.getBoundingClientRect === 'function') {
|
||
const containerRect = container.getBoundingClientRect();
|
||
const elRect = el.getBoundingClientRect();
|
||
const delta = (elRect.top - containerRect.top) - (container.clientHeight / 2);
|
||
container.scrollTop += delta;
|
||
} else if (typeof el.scrollIntoView === 'function') {
|
||
el.scrollIntoView({ block: 'start', behavior: 'auto' });
|
||
}
|
||
triggerAnchorHighlight(el, highlightMs);
|
||
if (typeof window !== 'undefined' && target.sessionId) {
|
||
window.location.hash = formatAnchorHash(target.sessionId, target.messageId);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// 3. 慢路径: 跨日加载
|
||
if (ctx && ctx.loadDayForAnchor) {
|
||
try {
|
||
const loaded = await ctx.loadDayForAnchor(target.messageId);
|
||
if (!loaded) {
|
||
return false;
|
||
}
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||
|
||
// 再次尝试虚拟化定位
|
||
const vHandleSlow = _virtualHandle;
|
||
if (vHandleSlow) {
|
||
vHandleSlow.scrollToMessageId(target.messageId);
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
tryHighlight(elementId);
|
||
return true;
|
||
}
|
||
|
||
el = document.getElementById(elementId);
|
||
if (el) {
|
||
el.scrollIntoView({ block: 'center', behavior: 'auto' });
|
||
triggerAnchorHighlight(el, highlightMs);
|
||
if (typeof window !== 'undefined' && target.sessionId) {
|
||
window.location.hash = formatAnchorHash(target.sessionId, target.messageId);
|
||
}
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
console.warn('[anchorNavigator] Slow path navigation failed:', e);
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|