feat(renderer): interactive table with sort, filter & CSV export (M10.9)
- InteractiveTable.vue: sortable columns, row filter, CSV export - Extracts markdown tables from AI responses into structured data - Click column header to sort (asc/desc), numeric-aware - Filter input for live row filtering - Renders alongside regular chat markdown Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9404085668
commit
04a175171b
@@ -200,6 +200,15 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="inlineTables.length > 0" class="mt-3 space-y-2" @click.stop>
|
||||
<InteractiveTable
|
||||
v-for="(table, i) in inlineTables"
|
||||
:key="`table-${i}`"
|
||||
:headers="table.headers"
|
||||
:rows="table.rows"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="inlineNewsLinks.length > 0" class="mt-3 space-y-1" @click.stop>
|
||||
<NewsCard
|
||||
v-for="(link, i) in inlineNewsLinks"
|
||||
@@ -346,6 +355,8 @@ import { extractCashuTokens } from '@/utils/cashu'
|
||||
import { extractRecipes, extractEvents } from '@/composables/contentExtraction'
|
||||
import RecipeCard from '@/components/renderers/RecipeCard.vue'
|
||||
import EventCard from '@/components/renderers/EventCard.vue'
|
||||
import InteractiveTable from '@/components/renderers/InteractiveTable.vue'
|
||||
import { extractTables } from '@/composables/useTableExtractor'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -443,6 +454,11 @@ const inlineRecipes = computed(() => {
|
||||
return extractRecipes(props.message.content)
|
||||
})
|
||||
|
||||
const inlineTables = computed(() => {
|
||||
if (isUser.value) return []
|
||||
return extractTables(props.message.content)
|
||||
})
|
||||
|
||||
const inlineEvents = computed(() => {
|
||||
if (isUser.value) return []
|
||||
return extractEvents(props.message.content)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="interactive-table my-4 rounded-lg bg-white/5 border border-white/5 overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-white/5">
|
||||
<input
|
||||
v-model="filterQuery"
|
||||
type="text"
|
||||
class="flex-1 bg-white/5 border border-white/10 rounded px-2 py-1 text-xs text-white/80 outline-none focus:border-accent/40 placeholder:text-white/25"
|
||||
placeholder="Filter rows..."
|
||||
/>
|
||||
<button
|
||||
class="text-[10px] px-2 py-1 rounded bg-white/5 border border-white/10 text-white/50 hover:text-white/80 hover:bg-white/10 transition-colors"
|
||||
title="Export CSV"
|
||||
@click="exportCsv"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-white/10">
|
||||
<th
|
||||
v-for="(header, col) in headers"
|
||||
:key="col"
|
||||
class="px-3 py-2 text-left text-white/50 font-medium cursor-pointer hover:text-white/70 select-none whitespace-nowrap"
|
||||
@click="toggleSort(col)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{{ header }}
|
||||
<span v-if="sortCol === col" class="text-accent">
|
||||
{{ sortDir === 'asc' ? '↑' : '↓' }}
|
||||
</span>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, i) in displayRows"
|
||||
:key="i"
|
||||
class="border-b border-white/5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<td
|
||||
v-for="(cell, j) in row"
|
||||
:key="j"
|
||||
class="px-3 py-1.5 text-white/70 whitespace-nowrap"
|
||||
>
|
||||
{{ cell }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredRows.length === 0" class="px-3 py-4 text-center text-xs text-white/30">
|
||||
No matching rows
|
||||
</div>
|
||||
|
||||
<div v-if="filteredRows.length > 0" class="px-3 py-1.5 text-[10px] text-white/25 border-t border-white/5">
|
||||
{{ filteredRows.length }} row{{ filteredRows.length === 1 ? '' : 's' }}
|
||||
<span v-if="filterQuery"> (filtered from {{ rows.length }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
}>()
|
||||
|
||||
const filterQuery = ref('')
|
||||
const sortCol = ref(-1)
|
||||
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||
|
||||
function toggleSort(col: number) {
|
||||
if (sortCol.value === col) {
|
||||
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortCol.value = col
|
||||
sortDir.value = 'asc'
|
||||
}
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const q = filterQuery.value.toLowerCase()
|
||||
if (!q) return props.rows
|
||||
return props.rows.filter((row) =>
|
||||
row.some((cell) => cell.toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
|
||||
const sortedRows = computed(() => {
|
||||
if (sortCol.value < 0) return filteredRows.value
|
||||
const col = sortCol.value
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1
|
||||
return [...filteredRows.value].sort((a, b) => {
|
||||
const aVal = a[col] || ''
|
||||
const bVal = b[col] || ''
|
||||
// Try numeric sort
|
||||
const aNum = parseFloat(aVal)
|
||||
const bNum = parseFloat(bVal)
|
||||
if (!isNaN(aNum) && !isNaN(bNum)) return (aNum - bNum) * dir
|
||||
return aVal.localeCompare(bVal) * dir
|
||||
})
|
||||
})
|
||||
|
||||
const displayRows = computed(() => sortedRows.value)
|
||||
|
||||
function exportCsv() {
|
||||
const header = props.headers.map(escapeCsvCell).join(',')
|
||||
const body = props.rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n')
|
||||
const csv = header + '\n' + body
|
||||
const blob = new Blob([csv], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'table.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function escapeCsvCell(cell: string): string {
|
||||
if (cell.includes(',') || cell.includes('"') || cell.includes('\n')) {
|
||||
return `"${cell.replace(/"/g, '""')}"`
|
||||
}
|
||||
return cell
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Extract markdown tables from text into structured data
|
||||
* for interactive table rendering.
|
||||
*/
|
||||
|
||||
export interface TableData {
|
||||
headers: string[]
|
||||
rows: string[][]
|
||||
raw: string
|
||||
}
|
||||
|
||||
// Matches a markdown table: header row, separator row, data rows
|
||||
const TABLE_RE = /^(\|[^\n]+\|)\n(\|[\s:|-]+\|)\n((?:\|[^\n]+\|\n?)+)/gm
|
||||
|
||||
export function extractTables(text: string): TableData[] {
|
||||
const results: TableData[] = []
|
||||
let m: RegExpExecArray | null
|
||||
const re = new RegExp(TABLE_RE.source, TABLE_RE.flags)
|
||||
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const headerLine = m[1]
|
||||
const dataBlock = m[3]
|
||||
|
||||
const headers = parseRow(headerLine)
|
||||
const rows = dataBlock
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(parseRow)
|
||||
.filter((r) => r.length > 0)
|
||||
|
||||
if (headers.length > 0 && rows.length > 0) {
|
||||
results.push({ headers, rows, raw: m[0] })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function parseRow(line: string): string[] {
|
||||
return line
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
}
|
||||
|
||||
export function hasTables(text: string): boolean {
|
||||
return TABLE_RE.test(text)
|
||||
}
|
||||
Reference in New Issue
Block a user