Files
EasyFlow/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-timeline/builder.ts
陈子默 f63cd9be4d fix: 保留多次上下文整理记录
- 为同一轮多次整理分配独立状态键

- 防止后续整理覆盖已完成的历史记录
2026-08-20 11:24:55 +08:00

918 lines
24 KiB
TypeScript

import type {
ChatArtifactAttachment,
ChatTimelineArtifactItem,
ChatTimelineItem,
ChatTimelineItemBase,
ChatTimelineKnowledgeHit,
ChatTimelineMessageItem,
ChatTimelineMessagePart,
ChatTimelineSkillInvocationStatus,
ChatTimelineStatusItem,
ChatTimelineStatusStatus,
ChatTimelineStatusTone,
ChatTimelineThinkingStatus,
ChatTimelineToolApprovalPayload,
ChatTimelineToolItem,
ChatTimelineToolMode,
ChatTimelineToolStatus,
} from './types';
type ChatTimelineTurnMetadata = Partial<
Pick<
ChatTimelineItemBase,
| 'roundCompleted'
| 'roundId'
| 'turnFinishedAt'
| 'turnStartedAt'
| 'turnSucceeded'
>
>;
function createId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function normalizeText(value: unknown) {
return value === null || value === undefined ? '' : String(value);
}
function normalizePayloadValue(value: unknown) {
if (value === null || value === undefined || value === '') {
return undefined;
}
if (typeof value === 'string') {
return value;
}
return value;
}
function normalizeToolName(value?: string) {
return normalizeText(value).trim().toLowerCase();
}
function isHiddenToolName(toolName?: string) {
const normalizedName = normalizeToolName(toolName);
return (
normalizedName === 'retrieve_knowledge' ||
normalizedName === 'context_reload' ||
normalizedName === '__fragment__'
);
}
function isKnowledgeRetrievalToolName(toolName?: string) {
return normalizeToolName(toolName) === 'retrieve_knowledge';
}
function isBlankToolName(toolName?: string) {
return !normalizeToolName(toolName);
}
function knowledgeRetrievalStatusKey(statusKey?: string) {
return normalizeText(statusKey).trim() || 'knowledge-retrieval';
}
function ensureMessageTail(
items: ChatTimelineItem[],
role: ChatTimelineMessageItem['role'],
status: ChatTimelineMessageItem['status'] = 'streaming',
metadata?: Partial<ChatTimelineMessageItem>,
) {
const last = items[items.length - 1];
if (
last?.type === 'message' &&
last.role === role &&
last.status !== 'done' &&
(!metadata?.roundId || last.roundId === metadata.roundId)
) {
last.status = status;
Object.assign(last, metadata);
return last;
}
const placeholder =
role === 'assistant' && metadata?.roundId
? [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
item.roundId === metadata.roundId &&
item.status !== 'done' &&
item.parts.length === 0,
)
: undefined;
if (placeholder) {
placeholder.status = status;
Object.assign(placeholder, metadata);
return placeholder;
}
const item: ChatTimelineMessageItem = {
id: createId(role),
role,
status,
createdAt: Date.now(),
parts: [],
type: 'message',
...metadata,
};
items.push(item);
return item;
}
function appendMessagePart(
message: ChatTimelineMessageItem,
part: ChatTimelineMessagePart,
) {
const tail = message.parts[message.parts.length - 1];
if (tail?.type === part.type) {
tail.content += part.content;
if (tail.type === 'thinking' && part.type === 'thinking') {
tail.status = part.status;
}
return;
}
message.parts.push(part);
}
function appendThinkingPart(
message: ChatTimelineMessageItem,
part: Extract<ChatTimelineMessagePart, { type: 'thinking' }>,
) {
appendMessagePart(message, part);
}
function appendTextPart(message: ChatTimelineMessageItem, content: string) {
appendMessagePart(message, {
id: createId('text'),
content,
type: 'text',
});
}
function replaceTextPart(message: ChatTimelineMessageItem, content: string) {
message.parts = [
...message.parts.filter((part) => part.type !== 'text'),
{
id: createId('text'),
content,
type: 'text' as const,
},
];
}
function updateThinkingStatus(
message: ChatTimelineMessageItem,
status: ChatTimelineThinkingStatus,
) {
message.parts = message.parts.map((part) =>
part.type === 'thinking' && part.status === 'thinking'
? { ...part, expanded: status === 'thinking', status }
: part,
);
}
function finishAssistantMessage(
items: ChatTimelineItem[],
roundCompleted: boolean,
roundId?: string,
) {
const lastMessage = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(!roundId || item.roundId === roundId),
);
if (!lastMessage) {
return;
}
updateThinkingStatus(lastMessage, 'end');
lastMessage.status = lastMessage.status === 'error' ? 'error' : 'done';
if (lastMessage.status === 'done' && roundCompleted) {
lastMessage.roundCompleted = true;
}
}
function findToolItem(
items: ChatTimelineItem[],
toolCallId?: string,
taskId?: string,
sourceToolCallId?: string,
approvalId?: string,
) {
const identities = new Set(
[toolCallId, sourceToolCallId]
.map((value) => normalizeText(value).trim())
.filter(Boolean),
);
const normalizedTaskId = normalizeText(taskId).trim();
const normalizedApprovalId = normalizeText(approvalId).trim();
if (identities.size === 0 && !normalizedTaskId && !normalizedApprovalId) {
return undefined;
}
return items.find(
(item): item is ChatTimelineToolItem =>
item.type === 'tool' &&
((Boolean(normalizedApprovalId) &&
item.approval?.approvalId === normalizedApprovalId) ||
(Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) ||
(item.toolCallId ? identities.has(item.toolCallId) : false)),
);
}
function findStatusItem(items: ChatTimelineItem[], statusKey: string) {
return items.find(
(item): item is ChatTimelineStatusItem =>
item.type === 'status' && item.statusKey === statusKey,
);
}
function resolveStatusOccurrenceKey(
items: ChatTimelineItem[],
statusKey: string,
roundId?: string,
) {
// 同一 Turn 可多次触发整理;只复用仍在运行的实例,避免改写已完成的历史状态。
const occurrencePrefix = `${statusKey}::occurrence:`;
const occurrences = items.filter(
(item): item is ChatTimelineStatusItem =>
item.type === 'status' &&
(item.statusKey === statusKey ||
item.statusKey.startsWith(occurrencePrefix)) &&
(!roundId || item.roundId === roundId),
);
const running = [...occurrences]
.reverse()
.find((item) => item.status === 'running');
if (running) {
return running.statusKey;
}
return occurrences.length === 0
? statusKey
: `${occurrencePrefix}${occurrences.length + 1}`;
}
function removeStatusItem(items: ChatTimelineItem[], statusKey: string) {
const index = items.findIndex(
(item) => item.type === 'status' && item.statusKey === statusKey,
);
if (index !== -1) {
items.splice(index, 1);
}
}
function doneStatusLabel(item: ChatTimelineStatusItem) {
if (item.statusKey === 'knowledge-retrieval') {
return '已检索知识库';
}
if (item.statusKey === 'memory-compression') {
return '已整理上下文';
}
return item.label.replace(/^正在/, '已');
}
function skillTerminalLabel(
label: string,
status: Extract<ChatTimelineStatusStatus, 'cancelled' | 'incomplete'>,
) {
const name = label.replace(/^正在调用\s*/, '').trim() || '技能';
return status === 'cancelled' ? `已停止调用 ${name}` : `调用 ${name} 未完成`;
}
function finishRunningStatusItems(
items: ChatTimelineItem[],
roundId?: string,
skillTerminalStatus: Extract<
ChatTimelineStatusStatus,
'cancelled' | 'incomplete'
> = 'incomplete',
) {
items.forEach((item) => {
if (
item.type !== 'status' ||
item.status !== 'running' ||
(roundId && item.roundId !== roundId)
) {
return;
}
if (item.icon === 'skill') {
item.status = skillTerminalStatus;
item.label = skillTerminalLabel(item.label, skillTerminalStatus);
return;
}
item.status = 'done';
item.label = doneStatusLabel(item);
});
}
function skillStatusPresentation(status: ChatTimelineSkillInvocationStatus) {
switch (status) {
case 'CANCELLED': {
return {
prefix: '已停止调用',
status: 'cancelled' as const,
};
}
case 'FAILED': {
return { prefix: '调用', status: 'error' as const, suffix: '失败' };
}
case 'INCOMPLETE': {
return {
prefix: '调用',
status: 'incomplete' as const,
suffix: '未完成',
};
}
case 'RUNNING': {
return { prefix: '正在调用', status: 'running' as const };
}
case 'SUCCESS': {
return { prefix: '已调用', status: 'done' as const };
}
}
}
function upsertStatus(
items: ChatTimelineItem[],
payload: ChatTimelineTurnMetadata & {
label: string;
presentation?: ChatTimelineStatusItem['presentation'];
status: ChatTimelineStatusStatus;
statusKey: string;
tone?: ChatTimelineStatusTone;
},
) {
const found = findStatusItem(items, payload.statusKey);
if (found) {
found.label = payload.label;
found.presentation = payload.presentation ?? found.presentation;
found.status = payload.status;
found.tone = payload.tone ?? found.tone;
applyTurnMetadata(found, payload);
return found;
}
const item: ChatTimelineStatusItem = {
id: payload.statusKey,
createdAt: Date.now(),
label: payload.label,
presentation: payload.presentation,
status: payload.status,
statusKey: payload.statusKey,
tone: payload.tone ?? 'muted',
type: 'status',
};
applyTurnMetadata(item, payload);
items.push(item);
return item;
}
function upsertTool(
items: ChatTimelineItem[],
payload: ChatTimelineTurnMetadata & {
approval?: ChatTimelineToolApprovalPayload;
approvalId?: string;
input?: unknown;
mode?: ChatTimelineToolMode;
output?: unknown;
rejectReason?: string;
sourceToolCallId?: string;
status?: ChatTimelineToolStatus;
taskId?: string;
toolCallId?: string;
toolName?: string;
},
) {
const toolCallId = normalizeText(
payload.toolCallId ?? payload.approval?.toolCallId,
).trim();
const taskId = normalizeText(payload.taskId).trim();
const found = findToolItem(
items,
toolCallId,
taskId,
payload.sourceToolCallId,
payload.approvalId ?? payload.approval?.approvalId,
);
const approval = payload.approval ?? found?.approval;
const mode =
payload.mode === 'approval'
? 'approval'
: (found?.mode ?? payload.mode ?? (approval ? 'approval' : 'auto'));
const toolName =
payload.toolName ||
approval?.toolDisplayName ||
approval?.toolName ||
found?.toolName;
if (isHiddenToolName(toolName)) {
return found;
}
if (!found && isBlankToolName(toolName)) {
return undefined;
}
if (found) {
found.approval = approval;
found.input =
normalizePayloadValue(payload.input) ??
normalizePayloadValue(approval?.input) ??
found.input;
found.mode = mode;
found.output = normalizePayloadValue(payload.output) ?? found.output;
found.rejectReason = payload.rejectReason ?? found.rejectReason;
found.status = payload.status || found.status;
found.taskId = taskId || found.taskId;
found.toolCallId = toolCallId || found.toolCallId;
found.toolName = toolName || found.toolName;
applyTurnMetadata(found, payload);
return found;
}
const toolItem: ChatTimelineToolItem = {
id: toolCallId || createId('tool'),
approval,
createdAt: Date.now(),
input:
normalizePayloadValue(payload.input) ??
normalizePayloadValue(approval?.input),
mode,
output: normalizePayloadValue(payload.output),
rejectReason: payload.rejectReason,
status:
payload.status || (mode === 'approval' ? 'pending_approval' : 'running'),
taskId: taskId || undefined,
toolCallId,
toolName: toolName || '工具调用',
type: 'tool',
};
applyTurnMetadata(toolItem, payload);
items.push(toolItem);
return toolItem;
}
function applyTurnMetadata(
item: ChatTimelineItemBase,
metadata?: ChatTimelineTurnMetadata,
) {
if (!metadata) {
return;
}
if (metadata.roundId) {
item.roundId = metadata.roundId;
}
if (metadata.roundCompleted !== undefined) {
item.roundCompleted = metadata.roundCompleted;
}
if (metadata.turnSucceeded !== undefined) {
item.turnSucceeded = metadata.turnSucceeded;
}
if (metadata.turnStartedAt !== undefined) {
item.turnStartedAt = Math.min(
item.turnStartedAt ?? metadata.turnStartedAt,
metadata.turnStartedAt,
);
}
if (metadata.turnFinishedAt !== undefined) {
item.turnFinishedAt = Math.max(
item.turnFinishedAt ?? metadata.turnFinishedAt,
metadata.turnFinishedAt,
);
}
}
export const ChatTimelineBuilder = {
ensureAssistantTurn(
items: ChatTimelineItem[],
metadata?: Partial<ChatTimelineMessageItem>,
) {
ensureMessageTail(items, 'assistant', 'streaming', metadata);
},
appendUserMessage(
items: ChatTimelineItem[],
content?: unknown,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(content);
if (!text && !metadata?.images?.length && !metadata?.documents?.length) {
return;
}
const item: ChatTimelineMessageItem = {
id: createId('user'),
role: 'user',
status: 'done',
createdAt: Date.now(),
parts: text
? [
{
id: createId('text'),
content: text,
type: 'text' as const,
},
]
: [],
type: 'message',
...metadata,
};
items.push(item);
},
appendThinkingDelta(
items: ChatTimelineItem[],
delta?: unknown,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(delta);
if (!text) {
return;
}
const message = ensureMessageTail(
items,
'assistant',
'streaming',
metadata,
);
if (message.parts.some((part) => part.type === 'text')) {
return;
}
appendThinkingPart(message, {
id: createId('thinking'),
content: text,
expanded: true,
status: 'thinking',
type: 'thinking',
});
},
appendMessageDelta(
items: ChatTimelineItem[],
delta?: unknown,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(delta);
if (!text) {
return;
}
const message = ensureMessageTail(
items,
'assistant',
'streaming',
metadata,
);
updateThinkingStatus(message, 'end');
appendTextPart(message, text);
},
replaceMessageContent(
items: ChatTimelineItem[],
content?: unknown,
metadata?: Partial<ChatTimelineMessageItem>,
) {
const text = normalizeText(content);
const message =
(metadata?.id
? items.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' && item.id === metadata.id,
)
: undefined) ||
[...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(!metadata?.roundId || item.roundId === metadata.roundId),
) ||
ensureMessageTail(items, 'assistant', 'done', metadata);
for (let index = items.length - 1; index >= 0; index--) {
const item = items[index];
if (
item === message ||
item?.type !== 'message' ||
item.role !== 'assistant' ||
(metadata?.roundId && item.roundId !== metadata.roundId)
) {
continue;
}
item.parts = item.parts.filter((part) => part.type !== 'text');
if (item.parts.length === 0) {
items.splice(index, 1);
}
}
Object.assign(message, metadata);
message.status = 'done';
updateThinkingStatus(message, 'end');
replaceTextPart(message, text);
},
appendToolApproval(
items: ChatTimelineItem[],
payload: ChatTimelineToolApprovalPayload,
metadata?: ChatTimelineTurnMetadata,
) {
upsertTool(items, {
...metadata,
approval: payload,
input: payload.input,
mode: 'approval',
status: 'pending_approval',
toolCallId: payload.toolCallId,
toolName: payload.toolDisplayName || payload.toolName,
});
},
upsertToolCall(
items: ChatTimelineItem[],
payload: ChatTimelineTurnMetadata & {
approvalId?: string;
input?: unknown;
output?: unknown;
sourceToolCallId?: string;
status?: ChatTimelineToolStatus;
statusKey?: string;
taskId?: string;
toolCallId?: string;
toolName?: string;
},
) {
if (isKnowledgeRetrievalToolName(payload.toolName)) {
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
items,
payload.status === 'success' ? 'done' : 'running',
payload.statusKey,
payload,
);
return;
}
upsertTool(items, {
...payload,
mode: 'auto',
status: payload.status || 'running',
});
},
upsertKnowledgeRetrievalStatus(
items: ChatTimelineItem[],
status: ChatTimelineStatusStatus,
statusKey?: string,
metadata?: ChatTimelineTurnMetadata,
) {
finishAssistantMessage(items, false, metadata?.roundId);
upsertStatus(items, {
...metadata,
label: status === 'running' ? '正在检索知识库' : '已检索知识库',
status,
statusKey: knowledgeRetrievalStatusKey(statusKey),
tone: 'muted',
});
},
upsertSkillInvocationStatus(
items: ChatTimelineItem[],
payload: ChatTimelineTurnMetadata & {
displayName?: string;
status: ChatTimelineSkillInvocationStatus;
statusKey: string;
},
) {
const displayName = normalizeText(payload.displayName).trim() || '技能';
const presentation = skillStatusPresentation(payload.status);
const label = [presentation.prefix, displayName, presentation.suffix]
.filter(Boolean)
.join(' ');
finishAssistantMessage(items, false, payload.roundId);
upsertStatus(items, {
...payload,
label,
status: presentation.status,
statusKey: payload.statusKey,
tone: payload.status === 'FAILED' ? 'danger' : 'muted',
}).icon = 'skill';
},
upsertMemoryCompressionStatus(
items: ChatTimelineItem[],
payload?: ChatTimelineTurnMetadata & {
compressed?: boolean;
label?: string;
phase?: string;
status?: string;
statusKey?: string;
},
) {
const status =
payload?.status === 'done' || payload?.phase === 'completed'
? 'done'
: 'running';
const statusKey = resolveStatusOccurrenceKey(
items,
payload?.statusKey || 'memory-compression',
payload?.roundId,
);
finishAssistantMessage(items, false, payload?.roundId);
if (status === 'done' && payload?.compressed === false) {
removeStatusItem(items, statusKey);
return;
}
const label =
status === 'running'
? payload?.label || '正在整理上下文'
: payload?.label || '已整理上下文';
upsertStatus(items, {
...payload,
label,
status,
statusKey,
presentation: 'separator',
tone: 'muted',
});
},
markToolApproving(
items: ChatTimelineItem[],
payload: ChatTimelineTurnMetadata & {
approvalId?: string;
toolCallId?: string;
},
) {
upsertTool(items, {
...payload,
mode: 'approval',
status: 'approving',
});
},
markToolRejected(
items: ChatTimelineItem[],
payload: ChatTimelineTurnMetadata & {
approvalId?: string;
reason?: string;
toolCallId?: string;
},
) {
upsertTool(items, {
...payload,
mode: 'approval',
rejectReason: payload.reason,
status: 'rejected',
});
},
appendKnowledge(
items: ChatTimelineItem[],
knowledgeItems: ChatTimelineKnowledgeHit[],
metadata?: ChatTimelineTurnMetadata,
) {
if (knowledgeItems.length === 0) {
return;
}
const lastAssistantMessage = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(!metadata?.roundId || item.roundId === metadata.roundId),
);
if (lastAssistantMessage) {
applyTurnMetadata(lastAssistantMessage, metadata);
lastAssistantMessage.knowledgeItems = [
...(lastAssistantMessage.knowledgeItems || []),
...knowledgeItems,
];
return;
}
const last = items[items.length - 1];
if (
last?.type === 'knowledge' &&
(!metadata?.roundId || last.roundId === metadata.roundId)
) {
applyTurnMetadata(last, metadata);
last.items.push(...knowledgeItems);
return;
}
const item = {
id: createId('knowledge'),
createdAt: Date.now(),
items: knowledgeItems,
type: 'knowledge' as const,
};
applyTurnMetadata(item, metadata);
items.push(item);
},
upsertArtifact(
items: ChatTimelineItem[],
artifact: ChatArtifactAttachment,
metadata?: ChatTimelineTurnMetadata,
) {
const artifactId = normalizeText(artifact.artifactId).trim();
const fileName = normalizeText(artifact.fileName).trim();
if (!artifactId || !fileName) {
return;
}
const existing = items.find(
(item): item is ChatTimelineArtifactItem =>
item.type === 'artifact' && item.artifactId === artifactId,
);
if (existing) {
existing.downloadUrl = artifact.downloadUrl;
existing.fileName = fileName;
existing.mimeType = artifact.mimeType;
existing.sha256 = artifact.sha256;
existing.size = artifact.size;
existing.status = artifact.status;
applyTurnMetadata(existing, metadata);
return;
}
const item = {
artifactId,
createdAt: Date.now(),
downloadUrl: artifact.downloadUrl,
fileName,
id: `artifact:${artifactId}`,
mimeType: artifact.mimeType,
sha256: artifact.sha256,
size: artifact.size,
status: artifact.status,
type: 'artifact' as const,
};
applyTurnMetadata(item, metadata);
items.push(item);
},
appendError(
items: ChatTimelineItem[],
message?: unknown,
metadata?: ChatTimelineTurnMetadata,
) {
const text = normalizeText(message) || '请求失败';
const last = [...items]
.reverse()
.find(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
(!metadata?.roundId || item.roundId === metadata.roundId),
);
if (last) {
updateThinkingStatus(last, 'error');
last.status = 'error';
}
const item = {
id: createId('error'),
createdAt: Date.now(),
message: text,
type: 'error' as const,
};
applyTurnMetadata(item, metadata);
items.push(item);
},
finalize(
items: ChatTimelineItem[],
metadata?: ChatTimelineTurnMetadata,
options?: {
runningSkillStatus?: Extract<
ChatTimelineStatusStatus,
'cancelled' | 'incomplete'
>;
},
) {
finishRunningStatusItems(
items,
metadata?.roundId,
options?.runningSkillStatus,
);
finishAssistantMessage(
items,
metadata?.turnSucceeded ?? true,
metadata?.roundId,
);
if (metadata?.roundId) {
for (const item of items) {
if (item.roundId === metadata.roundId) {
applyTurnMetadata(item, metadata);
}
}
}
},
replaceRoundAssistant(
items: ChatTimelineItem[],
roundId: string,
message: ChatTimelineMessageItem,
) {
const targetIndex = items.findIndex(
(item): item is ChatTimelineMessageItem =>
item.type === 'message' &&
item.role === 'assistant' &&
item.roundId === roundId,
);
if (targetIndex !== -1) {
items.splice(targetIndex, 1, message);
}
},
};