Files
Agent-System/web/src/engine/anchorNavigator.ts
T
weichen 3d6dcc3ae5 feat(web): 用小队子页面展示思考与工具过程
气泡只聚合过程摘要并链到 hash 子页面;成员编制来自只读 squad 端点,思考与工具默认折叠。
2026-09-28 10:30:53 +00:00

246 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 统一锚点定位协议引擎 (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;
}