feat: 重构聊天时间线与附件上传交互

- 管理端和用户中心统一切换到 chatTime 时间线模型,按真实 assistant/tool 时序渲染

- 收紧工具气泡与 JSON 展示样式,保留同气泡内的工具状态更新

- 回形针直接触发文件选择,附件列表上移到输入框上方并补充共享 helper 测试
This commit is contained in:
2026-05-11 21:25:21 +08:00
parent e27834ee0c
commit 4a15124183
27 changed files with 2527 additions and 751 deletions

View File

@@ -0,0 +1,96 @@
type ChatTimeTimelineRole = 'assistant' | 'tool' | 'user';
type ChatTimeToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
type ChatTimeThinkingStatus = 'end' | 'thinking';
interface ChatTimeTimelineItemBase {
created: number | string;
id: string;
loading?: boolean;
placement: 'end' | 'start';
role: ChatTimeTimelineRole;
senderName?: string;
typing?: boolean;
}
interface ChatTimeAssistantThinkingSegment {
content: string;
expanded: boolean;
id: string;
status: ChatTimeThinkingStatus;
type: 'thinking';
}
interface ChatTimeAssistantTextSegment {
content: string;
id: string;
type: 'text';
}
type ChatTimeAssistantSegment =
| ChatTimeAssistantTextSegment
| ChatTimeAssistantThinkingSegment;
interface ChatTimeAssistantItem extends ChatTimeTimelineItemBase {
content: string;
role: 'assistant';
segments: ChatTimeAssistantSegment[];
}
interface ChatTimeToolItem extends ChatTimeTimelineItemBase {
arguments?: string;
content: string;
name: string;
result?: string;
role: 'tool';
status: ChatTimeToolStatus;
toolCallId: string;
}
interface ChatTimeUserItem extends ChatTimeTimelineItemBase {
content: string;
role: 'user';
}
type ChatTimeTimelineItem =
| ChatTimeAssistantItem
| ChatTimeToolItem
| ChatTimeUserItem;
interface ChatTimeHistoryRecord {
chains?: Array<Record<string, any>>;
content?: string;
contentPayload?: null | Record<string, any>;
contentText?: string;
created?: number | string;
id?: number | string;
loading?: boolean;
placement?: 'end' | 'start';
role?: string;
senderName?: string;
senderRole?: string;
typing?: boolean;
}
interface ChatTimeToolMutationPayload {
created?: number | string;
name?: string;
result?: any;
toolCallId?: string;
value?: any;
}
export type {
ChatTimeAssistantItem,
ChatTimeAssistantSegment,
ChatTimeAssistantTextSegment,
ChatTimeAssistantThinkingSegment,
ChatTimeHistoryRecord,
ChatTimeThinkingStatus,
ChatTimeTimelineItem,
ChatTimeTimelineItemBase,
ChatTimeTimelineRole,
ChatTimeToolItem,
ChatTimeToolMutationPayload,
ChatTimeToolStatus,
ChatTimeUserItem,
};

View File

@@ -1,4 +1,5 @@
export type * from './api';
export type * from './bot';
export type * from './chat-time';
export type * from './user';
export type * from '@easyflow-core/typings';

View File

@@ -15,6 +15,7 @@
"dependencies": {
"@easyflow-core/shared": "workspace:*",
"@easyflow-core/typings": "workspace:*",
"@easyflow/types": "workspace:*",
"vue-router": "catalog:"
}
}

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import {
ChatTimeHistoryMapper,
ChatTimeTimelineBuilder,
} from '../chat-time';
describe('chat-time timeline builder', () => {
it('builds assistant thinking and message in the same assistant item', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendUserMessage(items, {
content: '你好',
created: 1,
id: 'user-1',
});
ChatTimeTimelineBuilder.appendThinkingDelta(items, '先想一下', 2);
ChatTimeTimelineBuilder.appendMessageDelta(items, '最终回答', 3);
ChatTimeTimelineBuilder.finalize(items);
expect(items).toHaveLength(2);
expect(items[1]).toMatchObject({
content: '最终回答',
role: 'assistant',
});
expect(items[1].segments).toMatchObject([
{ content: '先想一下', status: 'end', type: 'thinking' },
{ content: '最终回答', type: 'text' },
]);
});
it('creates a new assistant item after tool result', () => {
const items: any[] = [];
ChatTimeTimelineBuilder.appendMessageDelta(items, '第一段回答', 1);
ChatTimeTimelineBuilder.upsertToolCall(items, {
name: 'search_docs',
toolCallId: 'tool-1',
value: '{"query":"java"}',
});
ChatTimeTimelineBuilder.upsertToolResult(items, {
result: '{"hits":1}',
toolCallId: 'tool-1',
});
ChatTimeTimelineBuilder.appendThinkingDelta(items, '继续思考', 2);
ChatTimeTimelineBuilder.appendMessageDelta(items, '第二段回答', 3);
ChatTimeTimelineBuilder.finalize(items);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({ content: '第一段回答', role: 'assistant' });
expect(items[1]).toMatchObject({
name: 'search_docs',
result: '{"hits":1}',
role: 'tool',
status: 'TOOL_RESULT',
toolCallId: 'tool-1',
});
expect(items[2]).toMatchObject({ content: '第二段回答', role: 'assistant' });
expect(items[2].segments).toMatchObject([
{ content: '继续思考', status: 'end', type: 'thinking' },
{ content: '第二段回答', type: 'text' },
]);
});
});
describe('chat-time history mapper', () => {
it('expands structured messageChain into assistant and tool timeline items', () => {
const items = ChatTimeHistoryMapper.fromHistoryRecords([
{
contentPayload: {
messageChain: [
{
content: '先回答一点',
reasoningContent: '先思考',
role: 'assistant',
toolCalls: [
{
arguments: '{"query":"java"}',
id: 'tool-1',
name: 'search_docs',
},
],
},
{
content: '{"hits":1}',
role: 'tool',
toolCallId: 'tool-1',
},
{
content: '最后总结',
reasoningContent: '继续思考',
role: 'assistant',
},
],
},
created: 100,
id: 'assistant-record',
senderRole: 'assistant',
},
]);
expect(items).toHaveLength(3);
expect(items[0]).toMatchObject({
content: '先回答一点',
role: 'assistant',
});
expect(items[1]).toMatchObject({
arguments: '{"query":"java"}',
name: 'search_docs',
result: '{"hits":1}',
role: 'tool',
toolCallId: 'tool-1',
});
expect(items[2]).toMatchObject({
content: '最后总结',
role: 'assistant',
});
expect(items[0]?.id).not.toBe(items[2]?.id);
});
it('falls back to legacy chains when messageChain is unavailable', () => {
const items = ChatTimeHistoryMapper.fromLegacyMessages([
{
chains: [
{
reasoning_content: '旧思考',
thinkingStatus: 'end',
},
{
id: 'tool-2',
name: 'legacy_tool',
result: '{"ok":true}',
status: 'TOOL_RESULT',
},
],
content: '旧回答',
created: '2026-05-11 10:00:00',
id: 'legacy-1',
role: 'assistant',
},
]);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
content: '旧回答',
role: 'assistant',
});
expect(items[0].segments).toMatchObject([
{ content: '旧思考', status: 'end', type: 'thinking' },
{ content: '旧回答', type: 'text' },
]);
expect(items[1]).toMatchObject({
name: 'legacy_tool',
result: '{"ok":true}',
role: 'tool',
toolCallId: 'tool-2',
});
});
});

View File

@@ -0,0 +1,666 @@
import type {
ChatTimeAssistantItem,
ChatTimeHistoryRecord,
ChatTimeThinkingStatus,
ChatTimeTimelineItem,
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;
senderName?: string;
},
) {
items.push({
content: normalizePlainText(payload.content),
created: normalizeTimestamp(payload.created),
id: payload.id || uuid(),
placement: 'end',
role: 'user',
senderName: payload.senderName,
});
}
/**
* 追加思考增量。
*/
static appendThinkingDelta(
items: ChatTimeTimelineItem[],
delta?: string,
created?: number | string,
) {
const normalizedDelta = normalizePlainText(delta);
if (!normalizedDelta) {
return;
}
const assistant = ensureAssistantTail(items, created);
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,
) {
const normalizedDelta = normalizeAssistantText(delta);
if (!normalizedDelta) {
return;
}
const assistant = ensureAssistantTail(items, created);
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 stopThinking(items: ChatTimeTimelineItem[]) {
const last = items[items.length - 1];
if (!isAssistantItem(last)) {
return;
}
stopThinkingForAssistant(last);
}
/**
* 更新工具调用状态。
*/
static upsertToolCall(
items: ChatTimeTimelineItem[],
payload: ChatTimeToolMutationPayload,
) {
this.stopThinking(items);
const toolItem = ensureToolItem(
items,
payload.toolCallId,
payload.created,
payload.name,
);
toolItem.arguments = normalizePayloadValue(payload.value);
toolItem.content = '';
toolItem.status = 'TOOL_CALL';
}
/**
* 更新工具结果状态。
*/
static upsertToolResult(
items: ChatTimeTimelineItem[],
payload: ChatTimeToolMutationPayload,
) {
const toolItem = ensureToolItem(
items,
payload.toolCallId,
payload.created,
payload.name,
);
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 = items[items.length - 1];
if (!isAssistantItem(last)) {
return;
}
stopThinkingForAssistant(last);
last.loading = false;
last.typing = false;
}
}
/**
* 聊天时间线历史映射器。
*/
class ChatTimeHistoryMapper {
/**
* 从聊天历史记录恢复时间线。
*/
static fromHistoryRecords(records: ChatTimeHistoryRecord[]) {
return 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,
senderName: record.senderName,
typing: record.typing,
});
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);
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,
),
);
}
}
return items;
}
}
function createAssistantItem(
created?: number | string,
patch?: Partial<ChatTimeAssistantItem>,
): ChatTimeAssistantItem {
return {
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,
};
}
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,
senderName: record.senderName,
typing: false,
});
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,
) {
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(),
name,
result: status === 'TOOL_RESULT' ? argumentsValue : undefined,
status,
toolCallId,
});
}
function createToolItemFromStructuredMessage(
rawMessage: Record<string, any>,
toolMetaMap: Map<string, ChatTimeToolMeta>,
created?: number | string,
) {
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(),
name: toolMeta?.name,
result,
status: 'TOOL_RESULT',
toolCallId,
});
}
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),
name: normalizePlainText(payload.name),
result: normalizePayloadValue(
payload.content ?? payload.result ?? record.contentText ?? record.content,
),
status: 'TOOL_RESULT',
toolCallId,
});
}
function createToolItem(payload: {
arguments?: string;
created?: number | string;
id?: string;
name?: string;
result?: string;
status: ChatTimeToolStatus;
toolCallId?: string;
}): ChatTimeToolItem {
return {
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(),
};
}
function createUserItem(record: ChatTimeHistoryRecord): ChatTimeTimelineItem {
return {
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,
};
}
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,
) {
const last = items[items.length - 1];
if (isAssistantItem(last)) {
return last;
}
const assistant = createAssistantItem(created, {
loading: true,
typing: true,
});
items.push(assistant);
return assistant;
}
function ensureToolItem(
items: ChatTimeTimelineItem[],
toolCallId?: string,
created?: number | string,
name?: string,
) {
const normalizedToolCallId = normalizePlainText(toolCallId);
const found = findToolItem(items, normalizedToolCallId);
if (found) {
if (name) {
found.name = name;
}
return found;
}
const toolItem = createToolItem({
created,
id: normalizedToolCallId || uuid(),
name,
status: 'TOOL_CALL',
toolCallId: normalizedToolCallId,
});
items.push(toolItem);
return toolItem;
}
function findToolItem(items: ChatTimeTimelineItem[], toolCallId?: string) {
if (toolCallId) {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (isToolItem(item) && item.toolCallId === toolCallId) {
return item;
}
}
}
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (isToolItem(item) && item.status === 'TOOL_CALL') {
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 normalizeAssistantText(value: any) {
return normalizePlainText(value)
.replace(/^Final Answer:\s*/i, '')
.replaceAll('```echartsoption', '```echarts\noption');
}
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 };

View File

@@ -1,3 +1,4 @@
export * from './chat-time';
export * from './find-menu-by-path';
export * from './generate-menus';
export * from './generate-routes-backend';