Commit 53a84388 authored by 水玉婷's avatar 水玉婷
Browse files

feat:md中支持表格流式输出展示

parent c8ee53e3
......@@ -82,7 +82,7 @@
<textarea ref="textarea" v-model="messageText" placeholder="输入消息..." @keypress="handleKeyPress"
@input="adjustTextareaHeight" :disabled="loading"></textarea>
<button @click="sendMessage" :disabled="loading" class="send-button">
<button @click="sendMessage()" :disabled="loading" class="send-button">
<send-outlined />
</button>
</div>
......
......@@ -404,11 +404,6 @@ li {
z-index: 10;
transform: translateY(-50%);
&:active {
background-color: rgba(91, 138, 254, 0.1);
transform: translateY(-50%) scale(0.95);
}
&:disabled {
color: @gray-4;
border-color: @gray-4;
......
......@@ -8,7 +8,7 @@ import { markdownTemplate, isLastBlockMarkdown, getLastMarkdownBlockIndex, merge
thinking: (content: string) => string;
error: (content: string) => string;
table: (tableData: any) => string;
markdown: (content: any) => string;
markdown: (content: any, isStreaming?: boolean) => string;
option: (optionData: any) => string;
iframe: (iframeData: any) => string;
}
......@@ -95,8 +95,8 @@ export class ContentTemplateService {
},
// Markdown模板 - 使用独立的markdown模板工具
markdown: (content: any) => {
return markdownTemplate(content);
markdown: (content: any, isStreaming: boolean = false) => {
return markdownTemplate(content, isStreaming);
},
// 选项数据模板 - 纯渲染,不允许点击
......@@ -188,7 +188,7 @@ export class ContentTemplateService {
isHistoryData = false,
templateTools?: {
tableTemplate: (tableData: any) => string;
markdownTemplate: (content: any) => string;
markdownTemplate: (content: any, isStreaming?: boolean) => string;
isLastBlockMarkdown: (blocks: MessageBlock[]) => boolean;
getLastMarkdownBlockIndex: (blocks: MessageBlock[]) => number;
mergeMarkdownContent: (existing: string, newContent: string) => string;
......@@ -272,9 +272,10 @@ export class ContentTemplateService {
case 4: // MD格式
if (updatedResponse) {
// 对于SSE流式数据,使用流式处理
const markdownContent = templateTools?.markdownTemplate ?
templateTools.markdownTemplate(messageContent || '') :
this.templates.markdown(messageContent || '');
templateTools.markdownTemplate(messageContent || '', true) :
this.templates.markdown(messageContent || '', true);
// 检查最后一个块是否是markdown块
const isLastMarkdown = templateTools?.isLastBlockMarkdown ?
......@@ -414,11 +415,17 @@ export class ContentTemplateService {
}
// 处理历史记录数据
/**
* 处理历史记录数据,将其转换为可渲染的消息格式
* @param dataArray 包含历史记录数据的数组
* @param templateTools 包含模板工具函数的对象(可选)
* @returns 转换后的消息数组
*/
public processHistoryData(
dataArray: any[],
templateTools?: {
tableTemplate: (tableData: any) => string;
markdownTemplate: (content: any) => string;
markdownTemplate: (content: any, isStreaming?: boolean) => string;
isLastBlockMarkdown: (blocks: MessageBlock[]) => boolean;
getLastMarkdownBlockIndex: (blocks: MessageBlock[]) => number;
mergeMarkdownContent: (existing: string, newContent: string) => string;
......
......@@ -2,6 +2,41 @@
* Markdown模板工具类
* 用于解析和渲染Markdown内容
*/
/**
* 生成完整的表格HTML
* @param header 表头
* @param alignments 对齐方式
* @param dataRows 数据行
* @returns 表格HTML
*/
const generateCompleteTable = (header: string[], alignments: string[], dataRows: string[][]): string => {
let tableHtml = '<table class="markdown-table" style="border-collapse: collapse; width: 100%; border: 1px solid #ddd; margin: 10px 0;">\n';
// 表头
tableHtml += ' <thead>\n <tr>\n';
header.forEach((cell, index) => {
const align = alignments[index] || 'left';
tableHtml += ` <th style="text-align: ${align}; border: 1px solid #ddd; padding: 8px; background-color: #f5f5f5; font-weight: bold;">${cell}</th>\n`;
});
tableHtml += ' </tr>\n </thead>\n';
// 表体
if (dataRows.length > 0) {
tableHtml += ' <tbody>\n';
dataRows.forEach(row => {
tableHtml += ' <tr>\n';
row.forEach((cell, index) => {
const align = alignments[index] || 'left';
tableHtml += ` <td style="text-align: ${align}; border: 1px solid #ddd; padding: 8px;">${cell}</td>\n`;
});
tableHtml += ' </tr>\n';
});
tableHtml += ' </tbody>\n';
}
tableHtml += '</table>';
return tableHtml;
};
/**
* 增强的Markdown解析器
......@@ -22,81 +57,51 @@ export const parseMarkdown = (text: string): string => {
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
// 然后处理表格(在HTML转义之后)
// 对于完整文本,使用简单的表格处理逻辑
// 使用正则表达式处理表格,避免复杂的流式处理
text = text.replace(/(\|[^\n]+\|(?:\r?\n|\r))+/g, (match) => {
const lines = match.trim().split(/\r?\n/).filter(line => line.trim() && line.includes('|'));
if (lines.length < 1) return match;
if (lines.length < 2) return match;
try {
// 检查是否有分隔线
let hasSeparator = false;
let headerIndex = 0;
let separatorIndex = -1;
// 解析表头
const headerRow = lines[0];
const headerCells = headerRow.split('|').slice(1, -1).map(cell => cell.trim());
// 解析对齐方式
let alignments: string[] = [];
let dataStartIndex = 1;
// 查找分隔线(第二行通常是分隔线)
if (lines.length >= 2) {
const secondLine = lines[1].trim();
// 分隔线通常只包含 |、-、: 和空格
if (/^[\s|:\-]+$/.test(secondLine.replace(/[^:\-|]/g, ''))) {
hasSeparator = true;
headerIndex = 0;
separatorIndex = 1;
// 查找分隔线
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const cells = line.split('|').slice(1, -1).map(cell => cell.trim());
if (cells.every(cell => /^:?-+:?$/.test(cell))) {
alignments = cells.map(cell => {
if (cell.startsWith(':') && cell.endsWith(':')) return 'center';
if (cell.endsWith(':')) return 'right';
return 'left';
});
dataStartIndex = i + 1;
break;
}
}
// 解析表头
const header = parseTableRow(lines[headerIndex]);
// 确定列对齐方式
const alignments = header.map(() => 'left'); // 默认左对齐
if (hasSeparator && separatorIndex !== -1) {
const separatorCells = parseTableRow(lines[separatorIndex]);
separatorCells.forEach((cell, index) => {
const content = cell.trim();
if (content.startsWith(':') && content.endsWith(':')) {
alignments[index] = 'center';
} else if (content.startsWith(':')) {
alignments[index] = 'left';
} else if (content.endsWith(':')) {
alignments[index] = 'right';
}
});
// 如果没有找到分隔线,使用默认对齐方式
if (alignments.length === 0) {
alignments = headerCells.map(() => 'left');
}
// 解析数据行
const dataRows = [];
const startIndex = hasSeparator ? 2 : 1;
for (let i = startIndex; i < lines.length; i++) {
dataRows.push(parseTableRow(lines[i]));
}
// 生成表格HTML
let tableHtml = '<table class="markdown-table" style="border-collapse: collapse; width: 100%; border: 1px solid #ddd; margin: 10px 0;">\n';
// 表头
tableHtml += ' <thead>\n <tr>\n';
header.forEach((cell, index) => {
const align = alignments[index] || 'left';
tableHtml += ` <th style="text-align: ${align}; border: 1px solid #ddd; padding: 8px; background-color: #f5f5f5; font-weight: bold;">${cell}</th>\n`;
});
tableHtml += ' </tr>\n </thead>\n';
// 表体
if (dataRows.length > 0) {
tableHtml += ' <tbody>\n';
dataRows.forEach(row => {
tableHtml += ' <tr>\n';
row.forEach((cell, index) => {
const align = alignments[index] || 'left';
tableHtml += ` <td style="text-align: ${align}; border: 1px solid #ddd; padding: 8px;">${cell}</td>\n`;
});
tableHtml += ' </tr>\n';
});
tableHtml += ' </tbody>\n';
const dataRows: string[][] = [];
for (let i = dataStartIndex; i < lines.length; i++) {
const row = lines[i];
const cells = row.split('|').slice(1, -1).map(cell => cell.trim());
dataRows.push(cells);
}
tableHtml += '</table>';
return tableHtml;
// 生成完整表格HTML
return generateCompleteTable(headerCells, alignments, dataRows);
} catch (error) {
console.warn('表格解析失败:', error);
return match;
......@@ -166,25 +171,15 @@ export const parseMarkdown = (text: string): string => {
return text;
};
/**
* 改进的表格行解析
* @param line 表格行文本
* @returns 单元格数组
*/
const parseTableRow = (line: string): string[] => {
// 移除行首尾的 | 和空格
const trimmedLine = line.trim().replace(/^\||\|$/g, '');
// 按 | 分割,保留空单元格
const cells = trimmedLine.split('|').map(cell => cell.trim());
return cells;
};
/**
* Markdown模板函数
* @param content 要处理的Markdown内容
* @param isStreaming 是否使用流式处理(针对SSE逐条返回的数据)
* @returns 渲染后的HTML字符串
*/
export const markdownTemplate = (content: any): string => {
export const markdownTemplate = (content: any, isStreaming: boolean = false): string => {
// 类型检查和默认值处理
if (typeof content !== 'string') {
// 如果是对象,尝试转换为字符串
......@@ -196,7 +191,8 @@ export const markdownTemplate = (content: any): string => {
}
}
const htmlContent = parseMarkdown(content);
// 根据是否流式处理选择不同的解析方式
const htmlContent = isStreaming ? processStreamingMarkdown(content) : parseMarkdown(content);
// 清理HTML内容:移除br标签和空p段落
const cleanHtml = htmlContent
......@@ -227,10 +223,11 @@ export const markdownTemplate = (content: any): string => {
/**
* 简化的Markdown生成函数(兼容旧版本)
* @param content Markdown内容
* @param isStreaming 是否使用流式处理(可选参数)
* @returns 渲染后的HTML字符串
*/
export const markdown = (content: any): string => {
return markdownTemplate(content);
export const markdown = (content: any, isStreaming: boolean = false): string => {
return markdownTemplate(content, isStreaming);
};
/**
......@@ -242,6 +239,212 @@ export const isMarkdownBlock = (contentBlock: any): boolean => {
return contentBlock && contentBlock.content && contentBlock.content.includes('message-markdown');
};
/**
* 检查文本是否是表格行
* @param text 要检查的文本
* @returns 是否是表格行
*/
export const isTableRow = (text: string): boolean => {
if (!text || typeof text !== 'string') {
return false;
}
const trimmedText = text.trim();
return trimmedText.startsWith('|') && trimmedText.endsWith('|');
};
/**
* 检查文本是否是表格分隔线
* @param text 要检查的文本
* @returns 是否是表格分隔线
*/
export const isTableSeparator = (text: string): boolean => {
if (!text || typeof text !== 'string') {
return false;
}
const trimmedText = text.trim();
if (!trimmedText.startsWith('|') || !trimmedText.endsWith('|')) {
return false;
}
const cells = trimmedText.split('|').slice(1, -1).map(cell => cell.trim());
return cells.every(cell => /^:?-+:?$/.test(cell));
};
/**
* 检查文本是否是表格的开始(表头)
* @param text 要检查的文本
* @returns 是否是表格开始
*/
export const isTableStart = (text: string): boolean => {
if (!text || typeof text !== 'string') {
return false;
}
const trimmedText = text.trim();
return trimmedText.startsWith('|') && trimmedText.endsWith('|') && !isTableSeparator(trimmedText);
};
/**
* 流式表格处理器
* 用于处理SSE逐条返回的表格数据
*/
class StreamingTableProcessor {
private tableData: string[] = [];
private isInTable: boolean = false;
/**
* 处理新的文本内容
* @param text 新的文本内容
* @returns 处理结果:如果是表格行返回null,表格完成时返回完整表格HTML
*/
public processText(text: string): string | null {
if (!text || typeof text !== 'string') {
return null;
}
const trimmedText = text.trim();
// 检查是否是表格行
if (isTableRow(trimmedText)) {
if (!this.isInTable) {
// 开始新的表格
this.isInTable = true;
this.tableData = [trimmedText];
return null; // 表格开始,不立即渲染
} else {
// 继续当前表格,收集所有表格行
this.tableData.push(trimmedText);
// 不立即生成表格,继续收集直到遇到非表格行
return null; // 继续收集表格行
}
} else {
// 非表格行
if (this.isInTable && this.tableData.length > 0) {
// 表格结束,处理缓存的表格数据
let result: string | null = null;
// 检查表格是否至少包含表头和数据行(至少2行)
if (this.tableData.length >= 2) {
// 表格完整,生成完整表格
result = this.generateCompleteTable();
} else {
// 表格不完整,显示加载提示
result = '<div class="table-loading" style="border: 1px solid #ddd; padding: 10px; background-color: #f9f9f9; margin: 10px 0;">表格数据加载中...</div>';
}
this.reset();
return result;
}
// 普通文本,直接返回
return text;
}
}
/**
* 生成完整表格HTML
* @returns 表格HTML
*/
private generateCompleteTable(): string {
if (this.tableData.length < 2) {
return '';
}
try {
// 解析表头
const headerRow = this.tableData[0];
const headerCells = headerRow.split('|').slice(1, -1).map(cell => cell.trim());
// 解析对齐方式(从第二行)
let alignments: string[] = [];
let dataStartIndex = 1;
// 查找分隔线
for (let i = 1; i < this.tableData.length; i++) {
if (isTableSeparator(this.tableData[i])) {
const alignRow = this.tableData[i];
const alignCells = alignRow.split('|').slice(1, -1).map(cell => cell.trim());
alignments = alignCells.map(cell => {
if (cell.startsWith(':') && cell.endsWith(':')) return 'center';
if (cell.endsWith(':')) return 'right';
return 'left';
});
dataStartIndex = i + 1;
break;
}
}
// 如果没有找到分隔线,使用默认对齐方式,数据从第二行开始
if (alignments.length === 0) {
alignments = headerCells.map(() => 'left');
dataStartIndex = 1; // 对于无分隔线表格,数据从第二行开始
}
// 解析数据行
const dataRows: string[][] = [];
for (let i = dataStartIndex; i < this.tableData.length; i++) {
const row = this.tableData[i];
const cells = row.split('|').slice(1, -1).map(cell => cell.trim());
dataRows.push(cells);
}
// 生成完整表格HTML
return generateCompleteTable(headerCells, alignments, dataRows);
} catch (error) {
console.warn('表格生成失败:', error);
return '<div class="table-error" style="border: 1px solid #ff6b6b; padding: 10px; background-color: #ffeaea; margin: 10px 0;">表格渲染失败</div>';
}
}
/**
* 重置表格处理器状态
*/
private reset(): void {
this.tableData = [];
this.isInTable = false;
}
/**
* 获取当前表格状态
*/
public getTableState(): { isInTable: boolean; tableData: string[] } {
return {
isInTable: this.isInTable,
tableData: [...this.tableData]
};
}
}
// 创建全局表格处理器实例
const streamingTableProcessor = new StreamingTableProcessor();
/**
* 处理流式Markdown文本(专门用于SSE逐条返回的数据)
* @param text 新的文本内容
* @returns 处理后的HTML内容
*/
export const processStreamingMarkdown = (text: string): string => {
// 清理HTML标签,防止XSS攻击
text = text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
text = text.replace(/<[^>]*>/g, '');
// 使用流式表格处理器处理文本
const result = streamingTableProcessor.processText(text);
if (result === null) {
// 表格行被收集,不立即渲染
return '';
}
// 如果表格处理器返回了完整表格,直接返回
if (result.includes('<table')) {
return result;
}
// 否则,处理其他Markdown语法
return parseMarkdown(result);
};
/**
* 检查最后一个contentBlock是否是markdown块
* @param contentBlocks 内容块数组
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment