feat: SQL实验室支持总结

This commit is contained in:
digua
2025-12-09 00:46:56 +08:00
parent 3ac0885f94
commit 2c682faf02
5 changed files with 414 additions and 217 deletions
+1
View File
@@ -1,5 +1,6 @@
@import 'tailwindcss';
@import '@nuxt/ui';
@import './markdown.css';
/* 自定义主题颜色 - 使用 @theme static 定义色板 */
@theme static {
+210
View File
@@ -0,0 +1,210 @@
/**
* Markdown 渲染通用样式
* 用于 AI 消息、总结等需要渲染 Markdown 的地方
* 使用方式:在组件的 <style> 中 @import 此文件,或在全局样式中引入
*/
/* 段落 */
.prose :deep(p) {
margin: 0.5em 0;
line-height: 1.6;
}
.prose :deep(p:first-child) {
margin-top: 0;
}
.prose :deep(p:last-child) {
margin-bottom: 0;
}
/* 标题 */
.prose :deep(h1) {
font-size: 1.25rem;
font-weight: 700;
margin: 1em 0 0.5em;
line-height: 1.3;
}
.prose :deep(h2) {
font-size: 1.125rem;
font-weight: 600;
margin: 0.875em 0 0.5em;
line-height: 1.4;
}
.prose :deep(h3) {
font-size: 1rem;
font-weight: 600;
margin: 0.75em 0 0.375em;
line-height: 1.4;
}
.prose :deep(h1:first-child),
.prose :deep(h2:first-child),
.prose :deep(h3:first-child) {
margin-top: 0;
}
/* 列表 */
.prose :deep(ul),
.prose :deep(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
}
.prose :deep(ul) {
list-style-type: disc;
}
.prose :deep(ol) {
list-style-type: decimal;
}
.prose :deep(li) {
margin: 0.25em 0;
line-height: 1.5;
}
.prose :deep(li > p) {
margin: 0.25em 0;
}
/* 嵌套列表 */
.prose :deep(ul ul),
.prose :deep(ol ol),
.prose :deep(ul ol),
.prose :deep(ol ul) {
margin: 0.25em 0;
}
/* 代码 */
.prose :deep(code) {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
padding: 0.15em 0.4em;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.08);
}
.dark .prose :deep(code) {
background-color: rgba(255, 255, 255, 0.1);
}
/* 代码块 */
.prose :deep(pre) {
margin: 0.75em 0;
padding: 0.875em 1em;
border-radius: 0.5rem;
background-color: #1e293b;
overflow-x: auto;
}
.prose :deep(pre code) {
padding: 0;
background: none;
color: #e2e8f0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* 引用块 - 使用项目主题粉色 */
.prose :deep(blockquote) {
margin: 0.75em 0;
padding: 0.5em 0 0.5em 1em;
border-left: 3px solid var(--color-pink-500, #ee4567);
background-color: color-mix(in srgb, var(--color-pink-500, #ee4567) 5%, transparent);
border-radius: 0 0.25rem 0.25rem 0;
}
.prose :deep(blockquote p) {
margin: 0;
color: #6b7280;
}
.dark .prose :deep(blockquote p) {
color: #9ca3af;
}
/* 链接 - 使用项目主题粉色 */
.prose :deep(a) {
color: var(--color-pink-500, #ee4567);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :deep(a:hover) {
color: var(--color-pink-600, #de335e);
}
/* 分割线 */
.prose :deep(hr) {
margin: 1em 0;
border: none;
border-top: 1px solid #e5e7eb;
}
.dark .prose :deep(hr) {
border-top-color: #374151;
}
/* 加粗和斜体 */
.prose :deep(strong) {
font-weight: 600;
}
.prose :deep(em) {
font-style: italic;
}
/* 表格 */
.prose :deep(table) {
width: 100%;
margin: 0.75em 0;
border-collapse: collapse;
font-size: 0.875em;
}
.prose :deep(th),
.prose :deep(td) {
padding: 0.5em 0.75em;
border: 1px solid #e5e7eb;
text-align: left;
}
.dark .prose :deep(th),
.dark .prose :deep(td) {
border-color: #374151;
}
.prose :deep(th) {
background-color: #f9fafb;
font-weight: 600;
}
.dark .prose :deep(th) {
background-color: #1f2937;
}
/* 用户消息中的样式调整(白色文字背景) */
.prose-invert :deep(code) {
background-color: rgba(255, 255, 255, 0.2);
}
.prose-invert :deep(a) {
color: var(--color-pink-300, #faa7b4);
}
.prose-invert :deep(a:hover) {
color: var(--color-pink-200, #fccfd5);
}
.prose-invert :deep(blockquote) {
border-left-color: rgba(255, 255, 255, 0.5);
background-color: rgba(255, 255, 255, 0.1);
}
.prose-invert :deep(blockquote p) {
color: rgba(255, 255, 255, 0.8);
}
+4 -1
View File
@@ -17,6 +17,7 @@ const sql = ref('SELECT * FROM message LIMIT 10')
const isExecuting = ref(false)
const error = ref<string | null>(null)
const result = ref<SQLResult | null>(null)
const lastPrompt = ref('') // 记录最后使用的 AI 提示词
// 弹窗状态
const showAIModal = ref(false)
@@ -107,6 +108,7 @@ function handleInsertColumn(tableName: string, columnName: string) {
// 处理 AI 生成成功
function handleAIGenerated(generatedSql: string, explanation: string, prompt: string) {
addToHistory(prompt, generatedSql, explanation)
lastPrompt.value = prompt // 记录提示词
}
// 处理使用 SQL
@@ -123,6 +125,7 @@ async function handleRunSQL(generatedSql: string) {
// 从历史记录执行
async function executeFromHistory(record: AIHistory) {
sql.value = record.sql
lastPrompt.value = record.prompt // 记录历史的提示词
showHistoryModal.value = false
await executeSQL()
}
@@ -187,7 +190,7 @@ onMounted(() => {
</div>
<!-- 结果区域 -->
<ResultTable ref="resultTableRef" :result="result" :error="error" />
<ResultTable ref="resultTableRef" :result="result" :error="error" :sql="sql" :prompt="lastPrompt" />
</div>
<!-- AI 生成弹窗 -->
+2 -209
View File
@@ -244,10 +244,7 @@ function formatToolParams(tool: ContentBlock extends { type: 'tool'; tool: infer
<span class="font-medium text-gray-700 dark:text-gray-300">
{{ block.tool.displayName }}
</span>
<span
v-if="formatToolParams(block.tool)"
class="ml-2 text-xs text-gray-500 dark:text-gray-400"
>
<span v-if="formatToolParams(block.tool)" class="ml-2 text-xs text-gray-500 dark:text-gray-400">
{{ formatToolParams(block.tool) }}
</span>
</div>
@@ -272,208 +269,4 @@ function formatToolParams(tool: ContentBlock extends { type: 'tool'; tool: infer
</div>
</template>
<style scoped>
/* Markdown 渲染样式 */
.prose :deep(p) {
margin: 0.5em 0;
line-height: 1.6;
}
.prose :deep(p:first-child) {
margin-top: 0;
}
.prose :deep(p:last-child) {
margin-bottom: 0;
}
/* 标题 */
.prose :deep(h1) {
font-size: 1.25rem;
font-weight: 700;
margin: 1em 0 0.5em;
line-height: 1.3;
}
.prose :deep(h2) {
font-size: 1.125rem;
font-weight: 600;
margin: 0.875em 0 0.5em;
line-height: 1.4;
}
.prose :deep(h3) {
font-size: 1rem;
font-weight: 600;
margin: 0.75em 0 0.375em;
line-height: 1.4;
}
.prose :deep(h1:first-child),
.prose :deep(h2:first-child),
.prose :deep(h3:first-child) {
margin-top: 0;
}
/* 列表 */
.prose :deep(ul),
.prose :deep(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
}
.prose :deep(ul) {
list-style-type: disc;
}
.prose :deep(ol) {
list-style-type: decimal;
}
.prose :deep(li) {
margin: 0.25em 0;
line-height: 1.5;
}
.prose :deep(li > p) {
margin: 0.25em 0;
}
/* 嵌套列表 */
.prose :deep(ul ul),
.prose :deep(ol ol),
.prose :deep(ul ol),
.prose :deep(ol ul) {
margin: 0.25em 0;
}
/* 代码 */
.prose :deep(code) {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
padding: 0.15em 0.4em;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.08);
}
.dark .prose :deep(code) {
background-color: rgba(255, 255, 255, 0.1);
}
/* 代码块 */
.prose :deep(pre) {
margin: 0.75em 0;
padding: 0.875em 1em;
border-radius: 0.5rem;
background-color: #1e293b;
overflow-x: auto;
}
.prose :deep(pre code) {
padding: 0;
background: none;
color: #e2e8f0;
font-size: 0.8125rem;
line-height: 1.6;
}
/* 引用块 */
.prose :deep(blockquote) {
margin: 0.75em 0;
padding: 0.5em 0 0.5em 1em;
border-left: 3px solid #8b5cf6;
background-color: rgba(139, 92, 246, 0.05);
border-radius: 0 0.25rem 0.25rem 0;
}
.prose :deep(blockquote p) {
margin: 0;
color: #6b7280;
}
.dark .prose :deep(blockquote p) {
color: #9ca3af;
}
/* 链接 */
.prose :deep(a) {
color: #8b5cf6;
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :deep(a:hover) {
color: #7c3aed;
}
/* 分割线 */
.prose :deep(hr) {
margin: 1em 0;
border: none;
border-top: 1px solid #e5e7eb;
}
.dark .prose :deep(hr) {
border-top-color: #374151;
}
/* 加粗和斜体 */
.prose :deep(strong) {
font-weight: 600;
}
.prose :deep(em) {
font-style: italic;
}
/* 表格 */
.prose :deep(table) {
width: 100%;
margin: 0.75em 0;
border-collapse: collapse;
font-size: 0.875em;
}
.prose :deep(th),
.prose :deep(td) {
padding: 0.5em 0.75em;
border: 1px solid #e5e7eb;
text-align: left;
}
.dark .prose :deep(th),
.dark .prose :deep(td) {
border-color: #374151;
}
.prose :deep(th) {
background-color: #f9fafb;
font-weight: 600;
}
.dark .prose :deep(th) {
background-color: #1f2937;
}
/* 用户消息中的样式调整 */
.prose-invert :deep(code) {
background-color: rgba(255, 255, 255, 0.2);
}
.prose-invert :deep(a) {
color: #c4b5fd;
}
.prose-invert :deep(a:hover) {
color: #ddd6fe;
}
.prose-invert :deep(blockquote) {
border-left-color: rgba(255, 255, 255, 0.5);
background-color: rgba(255, 255, 255, 0.1);
}
.prose-invert :deep(blockquote p) {
color: rgba(255, 255, 255, 0.8);
}
</style>
<!-- Markdown 样式已提取到全局 src/assets/styles/markdown.css -->
+197 -7
View File
@@ -1,11 +1,23 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import MarkdownIt from 'markdown-it'
import type { SQLResult } from './types'
import { COLUMN_LABELS } from './types'
// 创建 markdown-it 实例
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true,
})
// Props
const props = defineProps<{
result: SQLResult | null
error: string | null
sql?: string // 当前 SQL 语句
prompt?: string // 用户提示词(AI 生成时)
}>()
// Emits
@@ -17,6 +29,28 @@ const emit = defineEmits<{
const sortColumn = ref<string | null>(null)
const sortDirection = ref<'asc' | 'desc'>('asc')
// AI 总结相关状态
const showSummaryModal = ref(false)
const isSummarizing = ref(false)
const summaryContent = ref('')
const summaryError = ref<string | null>(null)
const streamingContent = ref('')
// 获取列的中文标签(尝试匹配所有表的列)
function getColumnLabel(columnName: string): string | null {
// 处理带表名前缀的情况,如 "message.sender_id" 或 "m.sender_id"
const parts = columnName.split('.')
const colName = parts.length > 1 ? parts[parts.length - 1] : columnName
// 遍历所有表查找匹配
for (const tableColumns of Object.values(COLUMN_LABELS)) {
if (tableColumns[colName]) {
return tableColumns[colName]
}
}
return null
}
// 排序后的行数据
const sortedRows = computed(() => {
if (!props.result || !sortColumn.value) {
@@ -84,6 +118,108 @@ function resetSort() {
sortDirection.value = 'asc'
}
// ==================== AI 总结功能 ====================
// 构建总结结果的数据(取前 50 行避免 token 过多)
function buildResultSummary(): string {
if (!props.result) return ''
const maxRows = 50
const rows = props.result.rows.slice(0, maxRows)
// 构建表格形式的结果
const header = props.result.columns.join(' | ')
const separator = props.result.columns.map(() => '---').join(' | ')
const dataRows = rows.map((row) =>
row.map((cell) => (cell === null ? 'NULL' : String(cell).slice(0, 50))).join(' | ')
)
let resultText = `| ${header} |\n| ${separator} |\n`
resultText += dataRows.map((r) => `| ${r} |`).join('\n')
if (props.result.rows.length > maxRows) {
resultText += `\n\n(仅展示前 ${maxRows} 行,共 ${props.result.rowCount} 行)`
}
return resultText
}
// 打开总结弹窗并开始总结
async function openSummaryModal() {
showSummaryModal.value = true
summaryContent.value = ''
summaryError.value = null
streamingContent.value = ''
await generateSummary()
}
// AI 总结
async function generateSummary() {
const hasConfig = await window.llmApi.hasConfig()
if (!hasConfig) {
summaryError.value = '请先在设置中配置 AI 服务'
return
}
isSummarizing.value = true
summaryError.value = null
streamingContent.value = ''
try {
const resultSummary = buildResultSummary()
let contextInfo = ''
if (props.prompt) {
contextInfo = `用户的查询意图:${props.prompt}\n\n`
}
if (props.sql) {
contextInfo += `执行的 SQL 语句:\n\`\`\`sql\n${props.sql}\n\`\`\`\n\n`
}
const prompt = `请分析以下 SQL 查询结果,用简洁的中文总结关键发现和洞察。
${contextInfo}查询结果(共 ${props.result?.rowCount || 0} 行):
${resultSummary}
请提供:
1. 结果概述(一句话总结)
2. 关键发现(2-4 个要点)
3. 如有明显的趋势或异常,请指出`
const result = await window.llmApi.chatStream(
[
{ role: 'system', content: '你是一个数据分析专家,擅长从查询结果中提取关键信息和洞察。请用简洁清晰的中文回答。' },
{ role: 'user', content: prompt },
],
{ temperature: 0.3, maxTokens: 1000 },
(chunk) => {
if (chunk.content) {
streamingContent.value += chunk.content
}
}
)
if (result.success) {
summaryContent.value = streamingContent.value
} else {
summaryError.value = result.error || '总结失败'
}
} catch (err: any) {
summaryError.value = err.message || String(err)
} finally {
isSummarizing.value = false
}
}
// 关闭总结弹窗
function closeSummaryModal() {
showSummaryModal.value = false
summaryContent.value = ''
summaryError.value = null
streamingContent.value = ''
}
defineExpose({ resetSort })
</script>
@@ -116,10 +252,16 @@ defineExpose({ resetSort })
{{ result.duration }} ms
</span>
</div>
<UButton variant="ghost" size="xs" @click="copyAsCSV">
<UIcon name="i-heroicons-clipboard-document" class="mr-1 h-4 w-4" />
复制 CSV
</UButton>
<div class="flex items-center gap-2">
<UButton variant="ghost" size="xs" @click="openSummaryModal">
<UIcon name="i-heroicons-sparkles" class="mr-1 h-4 w-4" />
总结一下
</UButton>
<UButton variant="ghost" size="xs" @click="copyAsCSV">
<UIcon name="i-heroicons-clipboard-document" class="mr-1 h-4 w-4" />
复制 CSV
</UButton>
</div>
</div>
<!-- 结果表格 -->
@@ -130,15 +272,18 @@ defineExpose({ resetSort })
<th
v-for="(column, index) in result.columns"
:key="index"
class="cursor-pointer whitespace-nowrap px-4 py-2 text-left text-xs font-medium uppercase text-gray-500 transition-colors hover:bg-gray-200 dark:text-gray-400 dark:hover:bg-gray-700"
class="cursor-pointer whitespace-nowrap px-4 py-2 text-left text-xs font-medium transition-colors hover:bg-gray-200 dark:hover:bg-gray-700"
@click="handleSort(column)"
>
<div class="flex items-center gap-1">
<span>{{ column }}</span>
<div class="flex flex-col">
<span class="text-gray-700 dark:text-gray-300">{{ getColumnLabel(column) || column }}</span>
<span v-if="getColumnLabel(column)" class="font-mono text-[10px] text-gray-400">{{ column }}</span>
</div>
<UIcon
v-if="sortColumn === column"
:name="sortDirection === 'asc' ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'"
class="h-3 w-3"
class="h-3 w-3 text-gray-500"
/>
</div>
</th>
@@ -179,6 +324,51 @@ defineExpose({ resetSort })
<p class="mt-1 text-xs text-gray-400">仅支持 SELECT 查询结果最多返回 1000 </p>
</div>
</div>
<!-- AI 总结弹窗 -->
<UModal v-model:open="showSummaryModal">
<template #content>
<div class="max-h-[70vh] overflow-hidden p-6">
<div class="mb-4 flex items-center gap-2">
<UIcon name="i-heroicons-sparkles" class="h-5 w-5 text-pink-500" />
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">AI 结果总结</h3>
</div>
<!-- 加载状态 -->
<div v-if="isSummarizing && !streamingContent" class="flex items-center justify-center py-8">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
<span class="ml-2 text-sm text-gray-500">AI 正在分析结果...</span>
</div>
<!-- 流式输出 / 最终结果 -->
<div v-else-if="streamingContent || summaryContent" class="max-h-[50vh] overflow-y-auto">
<div
class="prose prose-sm max-w-none rounded-lg bg-gray-50 p-4 dark:prose-invert dark:bg-gray-900"
v-html="md.render(streamingContent || summaryContent)"
/>
<div v-if="isSummarizing" class="mt-2 flex items-center text-xs text-gray-400">
<UIcon name="i-heroicons-arrow-path" class="mr-1 h-3 w-3 animate-spin" />
生成中...
</div>
</div>
<!-- 错误提示 -->
<div v-if="summaryError" class="rounded-lg bg-red-50 p-4 dark:bg-red-950">
<p class="text-sm text-red-600 dark:text-red-400">{{ summaryError }}</p>
</div>
<!-- 底部按钮 -->
<div class="mt-4 flex justify-end gap-2">
<UButton v-if="!isSummarizing && summaryContent" variant="outline" @click="generateSummary">
<UIcon name="i-heroicons-arrow-path" class="mr-1 h-4 w-4" />
重新生成
</UButton>
<UButton variant="ghost" @click="closeSummaryModal">关闭</UButton>
</div>
</div>
</template>
</UModal>
</div>
</template>
<!-- Markdown 样式已提取到全局 src/assets/styles/markdown.css -->