1112 lines
29 KiB
TypeScript
1112 lines
29 KiB
TypeScript
import type {
|
|
ChatTimeAssistantItem,
|
|
ChatTimeHistoryRecord,
|
|
ChatTimeRoundMeta,
|
|
ChatTimeThinkingStatus,
|
|
ChatTimeTimelineItem,
|
|
ChatTimeTimelineItemBase,
|
|
ChatTimeToolItem,
|
|
ChatTimeToolMutationPayload,
|
|
ChatTimeToolStatus,
|
|
} from '../../../types/src/chat-time';
|
|
|
|
import {uuid} from './uuid';
|
|
|
|
type ChatTimeToolMeta = {
|
|
arguments?: string;
|
|
name?: string;
|
|
};
|
|
|
|
/**
|
|
* 聊天时间线实时构建器。
|
|
*/
|
|
class ChatTimeTimelineBuilder {
|
|
/**
|
|
* 追加用户消息。
|
|
*/
|
|
static appendUserMessage(
|
|
items: ChatTimeTimelineItem[],
|
|
payload: {
|
|
content?: string;
|
|
created?: number | string;
|
|
id?: string;
|
|
messageKind?: string;
|
|
roundId?: number | string;
|
|
roundNo?: number;
|
|
senderName?: string;
|
|
},
|
|
) {
|
|
const item: ChatTimeTimelineItem = {
|
|
content: normalizePlainText(payload.content),
|
|
created: normalizeTimestamp(payload.created),
|
|
id: payload.id || uuid(),
|
|
placement: 'end',
|
|
role: 'user',
|
|
senderName: payload.senderName,
|
|
};
|
|
applyRoundMeta(item, payload);
|
|
items.push(item);
|
|
}
|
|
|
|
/**
|
|
* 将最新一条待绑定的用户消息补齐到当前轮次。
|
|
*/
|
|
static bindLatestPendingUserMessage(
|
|
items: ChatTimeTimelineItem[],
|
|
meta?: ChatTimeRoundMeta,
|
|
) {
|
|
const roundId = normalizeRoundId(meta?.roundId);
|
|
if (!roundId) {
|
|
return;
|
|
}
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.role !== 'user') {
|
|
continue;
|
|
}
|
|
if (item.roundId) {
|
|
return;
|
|
}
|
|
applyRoundMeta(item, {
|
|
roundId,
|
|
roundNo: meta?.roundNo,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 更新指定轮次的可切换状态。
|
|
*/
|
|
static setRoundSwitchable(
|
|
items: ChatTimeTimelineItem[],
|
|
roundId: number | string | undefined,
|
|
switchable: boolean,
|
|
) {
|
|
const normalizedRoundId = normalizeRoundId(roundId);
|
|
if (!normalizedRoundId) {
|
|
return;
|
|
}
|
|
for (const item of items) {
|
|
if (item.roundId === normalizedRoundId && item.role !== 'user') {
|
|
item.switchable = switchable;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 追加思考增量。
|
|
*/
|
|
static appendThinkingDelta(
|
|
items: ChatTimeTimelineItem[],
|
|
delta?: string,
|
|
created?: number | string,
|
|
meta?: ChatTimeRoundMeta,
|
|
) {
|
|
const normalizedDelta = normalizePlainText(delta);
|
|
if (!normalizedDelta) {
|
|
return;
|
|
}
|
|
prepareRoundVariant(items, meta);
|
|
const assistant = ensureAssistantTail(items, created, meta);
|
|
const tail = assistant.segments[assistant.segments.length - 1];
|
|
if (tail?.type === 'thinking' && tail.status === 'thinking') {
|
|
tail.content += normalizedDelta;
|
|
} else {
|
|
assistant.segments.push({
|
|
content: normalizedDelta,
|
|
expanded: false,
|
|
id: uuid(),
|
|
status: 'thinking',
|
|
type: 'thinking',
|
|
});
|
|
}
|
|
assistant.loading = true;
|
|
assistant.typing = true;
|
|
}
|
|
|
|
/**
|
|
* 追加回答增量。
|
|
*/
|
|
static appendMessageDelta(
|
|
items: ChatTimeTimelineItem[],
|
|
delta?: string,
|
|
created?: number | string,
|
|
meta?: ChatTimeRoundMeta,
|
|
) {
|
|
const normalizedDelta = normalizeAssistantText(delta);
|
|
if (!normalizedDelta) {
|
|
return;
|
|
}
|
|
prepareRoundVariant(items, meta);
|
|
const assistant = ensureAssistantTail(items, created, meta);
|
|
stopThinkingForAssistant(assistant);
|
|
const tail = assistant.segments[assistant.segments.length - 1];
|
|
if (tail?.type === 'text') {
|
|
tail.content += normalizedDelta;
|
|
} else {
|
|
assistant.segments.push({
|
|
content: normalizedDelta,
|
|
id: uuid(),
|
|
type: 'text',
|
|
});
|
|
}
|
|
assistant.content += normalizedDelta;
|
|
assistant.loading = false;
|
|
assistant.typing = true;
|
|
}
|
|
|
|
/**
|
|
* 用最终完整回答替换当前 assistant 文本。
|
|
*/
|
|
static replaceMessageContent(
|
|
items: ChatTimeTimelineItem[],
|
|
content?: string,
|
|
created?: number | string,
|
|
meta?: ChatTimeRoundMeta,
|
|
) {
|
|
const normalizedContent = normalizeAssistantText(content);
|
|
if (!normalizedContent) {
|
|
return;
|
|
}
|
|
prepareRoundVariant(items, meta);
|
|
const assistant = ensureAssistantTail(items, created, meta);
|
|
stopThinkingForAssistant(assistant);
|
|
assistant.content = normalizedContent;
|
|
assistant.segments = [
|
|
...assistant.segments.filter((segment) => segment.type !== 'text'),
|
|
{
|
|
content: normalizedContent,
|
|
id: uuid(),
|
|
type: 'text' as const,
|
|
},
|
|
];
|
|
assistant.loading = false;
|
|
assistant.typing = false;
|
|
}
|
|
|
|
/**
|
|
* 停止当前 assistant 的思考态。
|
|
*/
|
|
static stopThinking(items: ChatTimeTimelineItem[]) {
|
|
const last = items[items.length - 1];
|
|
if (!isAssistantItem(last)) {
|
|
return;
|
|
}
|
|
stopThinkingForAssistant(last);
|
|
}
|
|
|
|
/**
|
|
* 更新工具调用状态。
|
|
*/
|
|
static upsertToolCall(
|
|
items: ChatTimeTimelineItem[],
|
|
payload: ChatTimeToolMutationPayload,
|
|
) {
|
|
prepareRoundVariant(items, payload);
|
|
this.stopThinking(items);
|
|
const toolItem = ensureToolItem(
|
|
items,
|
|
payload.toolCallId,
|
|
payload.created,
|
|
payload.name,
|
|
payload,
|
|
);
|
|
toolItem.arguments = normalizePayloadValue(payload.value);
|
|
toolItem.content = '';
|
|
toolItem.status = 'TOOL_CALL';
|
|
}
|
|
|
|
/**
|
|
* 更新工具结果状态。
|
|
*/
|
|
static upsertToolResult(
|
|
items: ChatTimeTimelineItem[],
|
|
payload: ChatTimeToolMutationPayload,
|
|
) {
|
|
prepareRoundVariant(items, payload);
|
|
const toolItem = ensureToolItem(
|
|
items,
|
|
payload.toolCallId,
|
|
payload.created,
|
|
payload.name,
|
|
payload,
|
|
);
|
|
toolItem.result = normalizePayloadValue(payload.result);
|
|
toolItem.content = toolItem.result;
|
|
toolItem.status = 'TOOL_RESULT';
|
|
}
|
|
|
|
/**
|
|
* 追加系统错误。
|
|
*/
|
|
static applySystemError(
|
|
items: ChatTimeTimelineItem[],
|
|
message?: string,
|
|
created?: number | string,
|
|
) {
|
|
const errorMessage = normalizePlainText(message);
|
|
if (!errorMessage) {
|
|
return;
|
|
}
|
|
const last = items[items.length - 1];
|
|
if (isAssistantItem(last)) {
|
|
stopThinkingForAssistant(last);
|
|
appendAssistantText(last, errorMessage);
|
|
last.loading = false;
|
|
last.typing = false;
|
|
return;
|
|
}
|
|
const assistant = createAssistantItem(created);
|
|
appendAssistantText(assistant, errorMessage);
|
|
assistant.loading = false;
|
|
assistant.typing = false;
|
|
items.push(assistant);
|
|
}
|
|
|
|
/**
|
|
* 结束当前轮的 assistant 状态。
|
|
*/
|
|
static finalize(items: ChatTimeTimelineItem[]) {
|
|
const last = findLastAssistant(items);
|
|
if (!isAssistantItem(last)) {
|
|
return;
|
|
}
|
|
stopThinkingForAssistant(last);
|
|
last.loading = false;
|
|
last.typing = false;
|
|
}
|
|
|
|
/**
|
|
* 按轮次替换当前主线可见的 assistant/tool 片段。
|
|
*/
|
|
static replaceRoundMessages(
|
|
items: ChatTimeTimelineItem[],
|
|
roundId: number | string | undefined,
|
|
nextMessages: ChatTimeTimelineItem[],
|
|
) {
|
|
const normalizedRoundId = normalizeRoundId(roundId);
|
|
if (!normalizedRoundId) {
|
|
return;
|
|
}
|
|
const range = resolveRoundReplaceRange(items, normalizedRoundId);
|
|
if (range) {
|
|
items.splice(range.start, range.deleteCount, ...nextMessages);
|
|
return;
|
|
}
|
|
items.splice(resolveRoundInsertIndex(items, normalizedRoundId), 0, ...nextMessages);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 聊天时间线历史映射器。
|
|
*/
|
|
class ChatTimeHistoryMapper {
|
|
/**
|
|
* 从聊天历史记录恢复时间线。
|
|
*/
|
|
static fromHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
|
return normalizeVisibleHistoryRecords(records).flatMap((record) =>
|
|
this.fromHistoryRecord(record),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 从旧版消息列表恢复时间线。
|
|
*/
|
|
static fromLegacyMessages(records: ChatTimeHistoryRecord[]) {
|
|
return records.flatMap((record) => this.fromLegacyRecord(record));
|
|
}
|
|
|
|
private static fromHistoryRecord(record: ChatTimeHistoryRecord) {
|
|
const role = normalizeRole(record.senderRole || record.role);
|
|
if (role === 'user') {
|
|
return [createUserItem(record)];
|
|
}
|
|
if (role === 'tool') {
|
|
return [createToolItemFromTopLevelRecord(record)];
|
|
}
|
|
if (role !== 'assistant') {
|
|
return [];
|
|
}
|
|
|
|
const payload = toObjectRecord(record.contentPayload);
|
|
const messageChain = toObjectArray(payload.messageChain);
|
|
if (messageChain.length > 0) {
|
|
const structuredItems = this.fromStructuredAssistantRecord(
|
|
record,
|
|
messageChain,
|
|
);
|
|
if (structuredItems.length > 0) {
|
|
return structuredItems;
|
|
}
|
|
}
|
|
return this.fromLegacyRecord(record);
|
|
}
|
|
|
|
private static fromLegacyRecord(record: ChatTimeHistoryRecord) {
|
|
const role = normalizeRole(record.senderRole || record.role);
|
|
if (role === 'user') {
|
|
return [createUserItem(record)];
|
|
}
|
|
if (role === 'tool') {
|
|
return [createToolItemFromTopLevelRecord(record)];
|
|
}
|
|
if (role !== 'assistant') {
|
|
return [];
|
|
}
|
|
|
|
const content = normalizeAssistantText(record.contentText || record.content);
|
|
const chains = extractDisplayChains(record);
|
|
const assistant = createAssistantItem(record.created, {
|
|
id: record.id == null ? undefined : String(record.id),
|
|
loading: record.loading,
|
|
messageKind: record.messageKind,
|
|
roundId: normalizeRoundId(record.roundId),
|
|
roundNo: record.roundNo,
|
|
selectedVariantIndex: record.selectedVariantIndex,
|
|
senderName: record.senderName,
|
|
switchable: record.switchable,
|
|
typing: record.typing,
|
|
variantCount: record.variantCount,
|
|
variantIndex: record.variantIndex,
|
|
});
|
|
const tools: ChatTimeTimelineItem[] = [];
|
|
|
|
for (const rawChain of chains) {
|
|
const reasoning = normalizePlainText(rawChain.reasoning_content);
|
|
if (reasoning) {
|
|
assistant.segments.push({
|
|
content: reasoning,
|
|
expanded: Boolean(rawChain.thinkingExpanded),
|
|
id: uuid(),
|
|
status: normalizeThinkingStatus(rawChain.thinkingStatus),
|
|
type: 'thinking',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const toolItem = createToolItemFromChain(rawChain, record.created, record);
|
|
if (toolItem) {
|
|
tools.push(toolItem);
|
|
}
|
|
}
|
|
|
|
if (content) {
|
|
appendAssistantText(assistant, content);
|
|
}
|
|
|
|
const results: ChatTimeTimelineItem[] = [];
|
|
if (assistant.segments.length > 0) {
|
|
assistant.loading = false;
|
|
assistant.typing = false;
|
|
results.push(assistant);
|
|
}
|
|
return [...results, ...tools];
|
|
}
|
|
|
|
private static fromStructuredAssistantRecord(
|
|
record: ChatTimeHistoryRecord,
|
|
messageChain: Array<Record<string, any>>,
|
|
) {
|
|
const toolMetaMap = new Map<string, ChatTimeToolMeta>();
|
|
const items: ChatTimeTimelineItem[] = [];
|
|
let assistantIndex = 0;
|
|
|
|
for (const rawMessage of messageChain) {
|
|
const role = normalizeRole(rawMessage.role);
|
|
if (role === 'assistant') {
|
|
collectToolMeta(rawMessage, toolMetaMap);
|
|
const assistant = createAssistantItemFromStructuredMessage(
|
|
rawMessage,
|
|
record,
|
|
assistantIndex,
|
|
);
|
|
if (assistant) {
|
|
items.push(assistant);
|
|
assistantIndex += 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (role === 'tool') {
|
|
items.push(
|
|
createToolItemFromStructuredMessage(
|
|
rawMessage,
|
|
toolMetaMap,
|
|
record.created,
|
|
record,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
}
|
|
|
|
function normalizeVisibleHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
|
const dedupedRecords = dedupeHistoryRecords(records);
|
|
const userSelectedVariantByRound = new Map<string, number>();
|
|
const assistantSelectedVariantByRound = new Map<string, number>();
|
|
const fallbackVariantByRound = new Map<string, number>();
|
|
for (const record of dedupedRecords) {
|
|
const roundId = normalizeRoundId(record.roundId);
|
|
if (!roundId) {
|
|
continue;
|
|
}
|
|
const selectedVariantIndex = normalizePositiveInteger(
|
|
record.selectedVariantIndex,
|
|
);
|
|
if (selectedVariantIndex) {
|
|
if (isUserHistoryRecord(record)) {
|
|
userSelectedVariantByRound.set(roundId, selectedVariantIndex);
|
|
} else {
|
|
assistantSelectedVariantByRound.set(roundId, selectedVariantIndex);
|
|
}
|
|
}
|
|
const variantIndex = normalizePositiveInteger(record.variantIndex);
|
|
if (!isUserHistoryRecord(record) && variantIndex) {
|
|
fallbackVariantByRound.set(roundId, variantIndex);
|
|
}
|
|
}
|
|
return dedupedRecords.filter((record) => {
|
|
const roundId = normalizeRoundId(record.roundId);
|
|
if (!roundId || isUserHistoryRecord(record)) {
|
|
return true;
|
|
}
|
|
const variantIndex = normalizePositiveInteger(record.variantIndex);
|
|
if (!variantIndex) {
|
|
return true;
|
|
}
|
|
const selectedVariantIndex =
|
|
userSelectedVariantByRound.get(roundId) ||
|
|
assistantSelectedVariantByRound.get(roundId) ||
|
|
fallbackVariantByRound.get(roundId);
|
|
return !selectedVariantIndex || variantIndex === selectedVariantIndex;
|
|
});
|
|
}
|
|
|
|
function dedupeHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
|
const seen = new Set<string>();
|
|
const result: ChatTimeHistoryRecord[] = [];
|
|
for (const record of records) {
|
|
const key = resolveHistoryRecordKey(record);
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
result.push(record);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function resolveHistoryRecordKey(record: ChatTimeHistoryRecord) {
|
|
if (record.id != null) {
|
|
return `id:${String(record.id)}`;
|
|
}
|
|
return [
|
|
'fallback',
|
|
normalizeRoundId(record.roundId) || '',
|
|
normalizeRole(record.senderRole || record.role),
|
|
normalizePositiveInteger(record.variantIndex) || '',
|
|
normalizePlainText(record.contentText || record.content),
|
|
].join(':');
|
|
}
|
|
|
|
function isUserHistoryRecord(record: ChatTimeHistoryRecord) {
|
|
return normalizeRole(record.senderRole || record.role) === 'user';
|
|
}
|
|
|
|
function createAssistantItem(
|
|
created?: number | string,
|
|
patch?: Omit<Partial<ChatTimeAssistantItem>, 'roundId'> & ChatTimeRoundMeta,
|
|
): ChatTimeAssistantItem {
|
|
const item: ChatTimeAssistantItem = {
|
|
content: patch?.content || '',
|
|
created: normalizeTimestamp(created),
|
|
id: patch?.id || uuid(),
|
|
loading: patch?.loading,
|
|
placement: 'start',
|
|
role: 'assistant',
|
|
segments: patch?.segments ? [...patch.segments] : [],
|
|
senderName: patch?.senderName,
|
|
typing: patch?.typing,
|
|
};
|
|
applyRoundMeta(item, patch);
|
|
return item;
|
|
}
|
|
|
|
function createAssistantItemFromStructuredMessage(
|
|
rawMessage: Record<string, any>,
|
|
record: ChatTimeHistoryRecord,
|
|
assistantIndex: number,
|
|
) {
|
|
const content = normalizeAssistantText(rawMessage.content);
|
|
const reasoning = normalizePlainText(
|
|
rawMessage.reasoningContent ?? rawMessage.reasoning_content,
|
|
);
|
|
if (!content && !reasoning) {
|
|
return null;
|
|
}
|
|
const assistant = createAssistantItem(record.created, {
|
|
id:
|
|
record.id == null
|
|
? undefined
|
|
: `${String(record.id)}-assistant-${assistantIndex}`,
|
|
loading: false,
|
|
messageKind: record.messageKind,
|
|
roundId: normalizeRoundId(record.roundId),
|
|
roundNo: record.roundNo,
|
|
selectedVariantIndex: record.selectedVariantIndex,
|
|
senderName: record.senderName,
|
|
switchable: record.switchable,
|
|
typing: false,
|
|
variantCount: record.variantCount,
|
|
variantIndex: record.variantIndex,
|
|
});
|
|
if (reasoning) {
|
|
assistant.segments.push({
|
|
content: reasoning,
|
|
expanded: false,
|
|
id: uuid(),
|
|
status: 'end',
|
|
type: 'thinking',
|
|
});
|
|
}
|
|
if (content) {
|
|
appendAssistantText(assistant, content);
|
|
}
|
|
return assistant;
|
|
}
|
|
|
|
function createToolItemFromChain(
|
|
rawChain: Record<string, any>,
|
|
created?: number | string,
|
|
record?: ChatTimeHistoryRecord,
|
|
) {
|
|
const toolCallId = normalizePlainText(rawChain.id);
|
|
const name = normalizePlainText(rawChain.name);
|
|
const argumentsValue = normalizePayloadValue(rawChain.arguments ?? rawChain.result);
|
|
const status = normalizeToolStatus(rawChain.status);
|
|
if (!toolCallId && !name && !argumentsValue) {
|
|
return null;
|
|
}
|
|
return createToolItem({
|
|
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
|
|
created,
|
|
id: toolCallId || uuid(),
|
|
messageKind: record?.messageKind,
|
|
name,
|
|
roundId: record?.roundId,
|
|
roundNo: record?.roundNo,
|
|
result: status === 'TOOL_RESULT' ? argumentsValue : undefined,
|
|
selectedVariantIndex: record?.selectedVariantIndex,
|
|
status,
|
|
switchable: record?.switchable,
|
|
toolCallId,
|
|
variantCount: record?.variantCount,
|
|
variantIndex: record?.variantIndex,
|
|
});
|
|
}
|
|
|
|
function createToolItemFromStructuredMessage(
|
|
rawMessage: Record<string, any>,
|
|
toolMetaMap: Map<string, ChatTimeToolMeta>,
|
|
created?: number | string,
|
|
record?: ChatTimeHistoryRecord,
|
|
) {
|
|
const toolCallId = normalizePlainText(
|
|
rawMessage.toolCallId ?? rawMessage.tool_call_id,
|
|
);
|
|
const toolMeta = toolMetaMap.get(toolCallId);
|
|
const result = normalizePayloadValue(rawMessage.content);
|
|
return createToolItem({
|
|
arguments: toolMeta?.arguments,
|
|
created,
|
|
id: toolCallId || uuid(),
|
|
messageKind: record?.messageKind,
|
|
name: toolMeta?.name,
|
|
roundId: record?.roundId,
|
|
roundNo: record?.roundNo,
|
|
result,
|
|
selectedVariantIndex: record?.selectedVariantIndex,
|
|
status: 'TOOL_RESULT',
|
|
switchable: record?.switchable,
|
|
toolCallId,
|
|
variantCount: record?.variantCount,
|
|
variantIndex: record?.variantIndex,
|
|
});
|
|
}
|
|
|
|
function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
|
|
const payload = toObjectRecord(record.contentPayload);
|
|
const toolCallId = normalizePlainText(
|
|
payload.toolCallId ?? payload.tool_call_id ?? record.id,
|
|
);
|
|
return createToolItem({
|
|
created: record.created,
|
|
id: record.id == null ? toolCallId || uuid() : String(record.id),
|
|
messageKind: record.messageKind,
|
|
name: normalizePlainText(payload.name),
|
|
roundId: record.roundId,
|
|
roundNo: record.roundNo,
|
|
result: normalizePayloadValue(
|
|
payload.content ?? payload.result ?? record.contentText ?? record.content,
|
|
),
|
|
selectedVariantIndex: record.selectedVariantIndex,
|
|
status: 'TOOL_RESULT',
|
|
switchable: record.switchable,
|
|
toolCallId,
|
|
variantCount: record.variantCount,
|
|
variantIndex: record.variantIndex,
|
|
});
|
|
}
|
|
|
|
function createToolItem(payload: {
|
|
arguments?: string;
|
|
created?: number | string;
|
|
id?: string;
|
|
messageKind?: string;
|
|
name?: string;
|
|
roundId?: number | string;
|
|
roundNo?: number;
|
|
result?: string;
|
|
selectedVariantIndex?: number;
|
|
status: ChatTimeToolStatus;
|
|
switchable?: boolean;
|
|
toolCallId?: string;
|
|
variantCount?: number;
|
|
variantIndex?: number;
|
|
}): ChatTimeToolItem {
|
|
const item: ChatTimeToolItem = {
|
|
arguments: payload.arguments,
|
|
content: payload.result || '',
|
|
created: normalizeTimestamp(payload.created),
|
|
id: payload.id || payload.toolCallId || uuid(),
|
|
name: payload.name || '',
|
|
placement: 'start',
|
|
result: payload.result,
|
|
role: 'tool',
|
|
status: payload.status,
|
|
toolCallId: payload.toolCallId || payload.id || uuid(),
|
|
};
|
|
applyRoundMeta(item, payload);
|
|
return item;
|
|
}
|
|
|
|
function createUserItem(record: ChatTimeHistoryRecord): ChatTimeTimelineItem {
|
|
const item: ChatTimeTimelineItem = {
|
|
content: normalizePlainText(record.contentText || record.content),
|
|
created: normalizeTimestamp(record.created),
|
|
id: record.id == null ? uuid() : String(record.id),
|
|
loading: record.loading,
|
|
placement: record.placement || 'end',
|
|
role: 'user',
|
|
senderName: record.senderName,
|
|
typing: record.typing,
|
|
};
|
|
applyRoundMeta(item, record);
|
|
return item;
|
|
}
|
|
|
|
function appendAssistantText(item: ChatTimeAssistantItem, content: string) {
|
|
const normalized = normalizeAssistantText(content);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
item.content += normalized;
|
|
item.segments.push({
|
|
content: normalized,
|
|
id: uuid(),
|
|
type: 'text',
|
|
});
|
|
}
|
|
|
|
function collectToolMeta(
|
|
rawMessage: Record<string, any>,
|
|
toolMetaMap: Map<string, ChatTimeToolMeta>,
|
|
) {
|
|
const toolCalls = toObjectArray(rawMessage.toolCalls ?? rawMessage.tool_calls);
|
|
for (const toolCall of toolCalls) {
|
|
const toolCallId = normalizePlainText(toolCall.id);
|
|
if (!toolCallId) {
|
|
continue;
|
|
}
|
|
toolMetaMap.set(toolCallId, {
|
|
arguments: normalizePayloadValue(toolCall.arguments),
|
|
name: normalizePlainText(toolCall.name),
|
|
});
|
|
}
|
|
}
|
|
|
|
function ensureAssistantTail(
|
|
items: ChatTimeTimelineItem[],
|
|
created?: number | string,
|
|
meta?: ChatTimeRoundMeta,
|
|
) {
|
|
const last = items[items.length - 1];
|
|
if (isAssistantItem(last) && isSameRoundVariant(last, meta)) {
|
|
applyRoundMeta(last, meta);
|
|
return last;
|
|
}
|
|
const assistant = createAssistantItem(created, {
|
|
loading: true,
|
|
typing: true,
|
|
...normalizeRoundMeta(meta),
|
|
});
|
|
items.push(assistant);
|
|
return assistant;
|
|
}
|
|
|
|
function ensureToolItem(
|
|
items: ChatTimeTimelineItem[],
|
|
toolCallId?: string,
|
|
created?: number | string,
|
|
name?: string,
|
|
meta?: ChatTimeRoundMeta,
|
|
) {
|
|
const normalizedToolCallId = normalizePlainText(toolCallId);
|
|
const found = findToolItem(items, normalizedToolCallId, meta);
|
|
if (found) {
|
|
if (name) {
|
|
found.name = name;
|
|
}
|
|
applyRoundMeta(found, meta);
|
|
return found;
|
|
}
|
|
const toolItem = createToolItem({
|
|
created,
|
|
id: normalizedToolCallId || uuid(),
|
|
messageKind: meta?.messageKind,
|
|
name,
|
|
roundId: meta?.roundId,
|
|
roundNo: meta?.roundNo,
|
|
selectedVariantIndex: meta?.selectedVariantIndex,
|
|
status: 'TOOL_CALL',
|
|
switchable: meta?.switchable,
|
|
toolCallId: normalizedToolCallId,
|
|
variantCount: meta?.variantCount,
|
|
variantIndex: meta?.variantIndex,
|
|
});
|
|
items.push(toolItem);
|
|
return toolItem;
|
|
}
|
|
|
|
function findToolItem(
|
|
items: ChatTimeTimelineItem[],
|
|
toolCallId?: string,
|
|
meta?: ChatTimeRoundMeta,
|
|
) {
|
|
const normalizedRoundId = normalizeRoundId(meta?.roundId);
|
|
const normalizedVariantIndex = normalizePositiveInteger(meta?.variantIndex);
|
|
if (toolCallId) {
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (
|
|
isToolItem(item) &&
|
|
item.toolCallId === toolCallId &&
|
|
matchesRoundVariant(item, normalizedRoundId, normalizedVariantIndex)
|
|
) {
|
|
return item;
|
|
}
|
|
}
|
|
}
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (
|
|
isToolItem(item) &&
|
|
item.status === 'TOOL_CALL' &&
|
|
matchesRoundVariant(item, normalizedRoundId, normalizedVariantIndex)
|
|
) {
|
|
return item;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractDisplayChains(record: ChatTimeHistoryRecord) {
|
|
if (Array.isArray(record.chains)) {
|
|
return record.chains.map((item) => toObjectRecord(item));
|
|
}
|
|
const payload = toObjectRecord(record.contentPayload);
|
|
if (Array.isArray(payload.chains)) {
|
|
return payload.chains.map((item: unknown) => toObjectRecord(item));
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function isAssistantItem(
|
|
item?: ChatTimeTimelineItem,
|
|
): item is ChatTimeAssistantItem {
|
|
return item?.role === 'assistant';
|
|
}
|
|
|
|
function isToolItem(item?: ChatTimeTimelineItem): item is ChatTimeToolItem {
|
|
return item?.role === 'tool';
|
|
}
|
|
|
|
function findLastAssistant(items: ChatTimeTimelineItem[]) {
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index];
|
|
if (isAssistantItem(item)) {
|
|
return item;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function prepareRoundVariant(
|
|
items: ChatTimeTimelineItem[],
|
|
meta?: ChatTimeRoundMeta,
|
|
) {
|
|
const normalizedRoundId = normalizeRoundId(meta?.roundId);
|
|
const normalizedVariantIndex = normalizePositiveInteger(meta?.variantIndex);
|
|
if (!normalizedRoundId || !normalizedVariantIndex) {
|
|
return;
|
|
}
|
|
const assistant = items.find(
|
|
(item) => item.role === 'assistant' && item.roundId === normalizedRoundId,
|
|
);
|
|
if (assistant?.variantIndex === normalizedVariantIndex) {
|
|
return;
|
|
}
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.roundId === normalizedRoundId && item.role !== 'user') {
|
|
items.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveRoundInsertIndex(
|
|
items: ChatTimeTimelineItem[],
|
|
roundId: string,
|
|
) {
|
|
const firstRoundItemIndex = items.findIndex(
|
|
(item) => item.roundId === roundId && item.role !== 'user',
|
|
);
|
|
if (firstRoundItemIndex >= 0) {
|
|
return firstRoundItemIndex;
|
|
}
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index];
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
if (item.roundId === roundId && item.role === 'user') {
|
|
return index + 1;
|
|
}
|
|
}
|
|
return items.length;
|
|
}
|
|
|
|
function resolveRoundReplaceRange(
|
|
items: ChatTimeTimelineItem[],
|
|
roundId: string,
|
|
) {
|
|
let start = -1;
|
|
let end = -1;
|
|
for (let index = 0; index < items.length; index += 1) {
|
|
const item = items[index];
|
|
if (item?.roundId === roundId && item.role !== 'user') {
|
|
if (start < 0) {
|
|
start = index;
|
|
}
|
|
end = index;
|
|
} else if (start >= 0) {
|
|
break;
|
|
}
|
|
}
|
|
if (start < 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
deleteCount: end - start + 1,
|
|
start,
|
|
};
|
|
}
|
|
|
|
function matchesRoundVariant(
|
|
item: ChatTimeTimelineItem,
|
|
roundId?: string,
|
|
variantIndex?: number,
|
|
) {
|
|
if (roundId && item.roundId !== roundId) {
|
|
return false;
|
|
}
|
|
if (variantIndex && item.variantIndex && item.variantIndex !== variantIndex) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isSameRoundVariant(
|
|
item: ChatTimeTimelineItem,
|
|
meta?: ChatTimeRoundMeta,
|
|
) {
|
|
const normalizedRoundId = normalizeRoundId(meta?.roundId);
|
|
const normalizedVariantIndex = normalizePositiveInteger(meta?.variantIndex);
|
|
if (!normalizedRoundId || !normalizedVariantIndex) {
|
|
return true;
|
|
}
|
|
return (
|
|
item.roundId === normalizedRoundId &&
|
|
normalizePositiveInteger(item.variantIndex) === normalizedVariantIndex
|
|
);
|
|
}
|
|
|
|
function applyRoundMeta(
|
|
target: Partial<ChatTimeTimelineItemBase>,
|
|
source?: ChatTimeRoundMeta | null,
|
|
) {
|
|
if (!source) {
|
|
return;
|
|
}
|
|
const roundId = normalizeRoundId(source.roundId);
|
|
if (roundId) {
|
|
target.roundId = roundId;
|
|
}
|
|
const roundNo = normalizePositiveInteger(source.roundNo);
|
|
if (roundNo) {
|
|
target.roundNo = roundNo;
|
|
}
|
|
const variantIndex = normalizePositiveInteger(source.variantIndex);
|
|
if (variantIndex) {
|
|
target.variantIndex = variantIndex;
|
|
}
|
|
const variantCount = normalizePositiveInteger(source.variantCount);
|
|
if (variantCount) {
|
|
target.variantCount = variantCount;
|
|
}
|
|
const selectedVariantIndex = normalizePositiveInteger(
|
|
source.selectedVariantIndex,
|
|
);
|
|
if (selectedVariantIndex) {
|
|
target.selectedVariantIndex = selectedVariantIndex;
|
|
}
|
|
if (typeof source.switchable === 'boolean') {
|
|
target.switchable = source.switchable;
|
|
}
|
|
const messageKind = normalizePlainText(source.messageKind).trim();
|
|
if (messageKind) {
|
|
target.messageKind = messageKind;
|
|
}
|
|
}
|
|
|
|
function normalizeRoundMeta(meta?: ChatTimeRoundMeta): ChatTimeRoundMeta {
|
|
return {
|
|
messageKind: meta?.messageKind,
|
|
roundId: normalizeRoundId(meta?.roundId),
|
|
roundNo: meta?.roundNo,
|
|
selectedVariantIndex: meta?.selectedVariantIndex,
|
|
switchable: meta?.switchable,
|
|
variantCount: meta?.variantCount,
|
|
variantIndex: meta?.variantIndex,
|
|
};
|
|
}
|
|
|
|
function normalizeRoundId(value: any) {
|
|
const normalized = normalizePlainText(value).trim();
|
|
return normalized || undefined;
|
|
}
|
|
|
|
function normalizePositiveInteger(value: any) {
|
|
if (value == null || value === '') {
|
|
return undefined;
|
|
}
|
|
const parsed = Number.parseInt(String(value), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
}
|
|
|
|
function normalizeAssistantText(value: any) {
|
|
return normalizePlainText(value);
|
|
}
|
|
|
|
function normalizePayloadValue(value: any) {
|
|
if (value == null) {
|
|
return '';
|
|
}
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function normalizePlainText(value: any) {
|
|
return typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
}
|
|
|
|
function normalizeRole(value: any) {
|
|
return normalizePlainText(value).trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeThinkingStatus(value: any): ChatTimeThinkingStatus {
|
|
return normalizeRole(value) === 'thinking' ? 'thinking' : 'end';
|
|
}
|
|
|
|
function normalizeTimestamp(value?: number | string) {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
if (typeof value === 'string' && value.trim()) {
|
|
const parsed = Date.parse(value);
|
|
return Number.isNaN(parsed) ? value : parsed;
|
|
}
|
|
return Date.now();
|
|
}
|
|
|
|
function normalizeToolStatus(value: any): ChatTimeToolStatus {
|
|
return normalizePlainText(value).toUpperCase() === 'TOOL_CALL'
|
|
? 'TOOL_CALL'
|
|
: 'TOOL_RESULT';
|
|
}
|
|
|
|
function stopThinkingForAssistant(item: ChatTimeAssistantItem) {
|
|
item.segments = item.segments.map(
|
|
(segment: ChatTimeAssistantItem['segments'][number]) => {
|
|
if (segment.type !== 'thinking' || segment.status !== 'thinking') {
|
|
return segment;
|
|
}
|
|
return {
|
|
...segment,
|
|
status: 'end' as ChatTimeThinkingStatus,
|
|
};
|
|
},
|
|
);
|
|
}
|
|
|
|
function toObjectArray(value: any): Array<Record<string, any>> {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
return value
|
|
.map((item: unknown) => toObjectRecord(item))
|
|
.filter((item) => Object.keys(item).length > 0);
|
|
}
|
|
|
|
function toObjectRecord(value: any): Record<string, any> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
return value as Record<string, any>;
|
|
}
|
|
|
|
export { ChatTimeHistoryMapper, ChatTimeTimelineBuilder };
|