918 lines
24 KiB
TypeScript
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);
|
|
}
|
|
},
|
|
};
|