diff --git a/web/app/components/workflow/skill/file-content-panel.tsx b/web/app/components/workflow/skill/file-content-panel.tsx index 0d619b95a3..cb465090dc 100644 --- a/web/app/components/workflow/skill/file-content-panel.tsx +++ b/web/app/components/workflow/skill/file-content-panel.tsx @@ -3,6 +3,7 @@ import type { OnMount } from '@monaco-editor/react' import type { FC } from 'react' import { loader } from '@monaco-editor/react' +import dynamic from 'next/dynamic' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -22,6 +23,11 @@ import { getFileLanguage } from './utils/file-utils' import MediaFilePreview from './viewer/media-file-preview' import UnsupportedFileDownload from './viewer/unsupported-file-download' +const SQLiteFilePreview = dynamic( + () => import('./viewer/sqlite-file-preview'), + { ssr: false, loading: () => }, +) + if (typeof window !== 'undefined') loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } }) @@ -43,7 +49,7 @@ const FileContentPanel: FC = () => { const currentFileNode = activeTabId ? nodeMap?.get(activeTabId) : undefined - const { isMarkdown, isCodeOrText, isImage, isVideo, isEditable } = useFileTypeInfo(currentFileNode) + const { isMarkdown, isCodeOrText, isImage, isVideo, isSQLite, isEditable } = useFileTypeInfo(currentFileNode) const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, activeTabId, isEditable) @@ -149,11 +155,11 @@ const FileContentPanel: FC = () => { ) } - // For non-editable files (media, unsupported), use download URL + // For non-editable files (media, sqlite, unsupported), use download URL const downloadUrl = downloadUrlData?.download_url || '' const fileName = currentFileNode?.name || '' const fileSize = currentFileNode?.size - const isUnsupportedFile = !isMarkdown && !isCodeOrText && !isImage && !isVideo + const isUnsupportedFile = !isMarkdown && !isCodeOrText && !isImage && !isVideo && !isSQLite return (
@@ -186,6 +192,14 @@ const FileContentPanel: FC = () => { /> ) : null} + {isSQLite + ? ( + + ) + : null} {isUnsupportedFile ? ( Promise +} + +type SQLiteModuleType = typeof import('wa-sqlite') +type SQLiteAPI = ReturnType +type SQLiteVFS = Parameters[0] + +type SQLiteClient = { + sqlite3: ReturnType + sqlite: SQLiteModuleType + vfs: MemoryVFS +} + +type SQLiteState = { + tables: string[] + isLoading: boolean + error: Error | null +} + +type SQLiteAction + = | { type: 'reset' } + | { type: 'loading' } + | { type: 'success', tables: string[] } + | { type: 'error', error: Error } + +const TABLES_QUERY = 'SELECT name FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\' ORDER BY name' +const DEFAULT_ROW_LIMIT = 200 + +let sqliteClientPromise: Promise | null = null + +function createTempFileName(): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) + return `preview-${crypto.randomUUID()}.db` + return `preview-${Date.now()}-${Math.random().toString(16).slice(2)}.db` +} + +async function getSQLiteClient(): Promise { + if (!sqliteClientPromise) { + sqliteClientPromise = (async () => { + const [{ default: SQLiteESMFactory }, sqlite, { MemoryVFS }] = await Promise.all([ + import('wa-sqlite/dist/wa-sqlite.mjs'), + import('wa-sqlite'), + import('wa-sqlite/src/examples/MemoryVFS.js'), + ]) + const sqliteModule = await SQLiteESMFactory() + const sqlite3 = sqlite.Factory(sqliteModule) + const vfs = new MemoryVFS() + sqlite3.vfs_register(vfs as unknown as SQLiteVFS, false) + return { + sqlite3, + sqlite, + vfs, + } + })() + } + return sqliteClientPromise +} + +export function useSQLiteDatabase(downloadUrl: string | undefined): UseSQLiteDatabaseResult { + const [state, dispatch] = useReducer((current: SQLiteState, action: SQLiteAction): SQLiteState => { + switch (action.type) { + case 'reset': + return { + tables: [], + isLoading: false, + error: null, + } + case 'loading': + return { + ...current, + isLoading: true, + error: null, + tables: [], + } + case 'success': + return { + tables: action.tables, + isLoading: false, + error: null, + } + case 'error': + return { + tables: [], + isLoading: false, + error: action.error, + } + default: + return current + } + }, { + tables: [], + isLoading: false, + error: null, + }) + const dbRef = useRef(null) + const fileRef = useRef(null) + const clientRef = useRef(null) + const cacheRef = useRef>(new Map()) + + const closeDatabase = useCallback(async () => { + const client = clientRef.current + const db = dbRef.current + const fileName = fileRef.current + + if (client && db !== null) { + try { + await client.sqlite3.close(db) + } + catch { + // Ignore cleanup errors. + } + } + + if (client && fileName) + client.vfs.mapNameToFile.delete(fileName) + + dbRef.current = null + fileRef.current = null + cacheRef.current.clear() + }, []) + + useEffect(() => { + if (!downloadUrl) { + dispatch({ type: 'reset' }) + void closeDatabase() + return + } + + let cancelled = false + const controller = new AbortController() + + const loadDatabase = async () => { + dispatch({ type: 'loading' }) + + try { + const [client, response] = await Promise.all([ + getSQLiteClient(), + fetch(downloadUrl, { signal: controller.signal }), + ]) + + if (cancelled) + return + + if (!response.ok) + throw new Error(`Failed to fetch database: ${response.status}`) + + const buffer = await response.arrayBuffer() + if (cancelled) + return + + await closeDatabase() + + const fileName = createTempFileName() + client.vfs.mapNameToFile.set(fileName, { + name: fileName, + flags: 0, + size: buffer.byteLength, + data: buffer, + }) + + const db = await client.sqlite3.open_v2( + fileName, + client.sqlite.SQLITE_OPEN_READONLY, + client.vfs.name, + ) + + if (cancelled) { + await client.sqlite3.close(db) + client.vfs.mapNameToFile.delete(fileName) + return + } + + clientRef.current = client + dbRef.current = db + fileRef.current = fileName + + const result = await client.sqlite3.execWithParams(db, TABLES_QUERY, []) + const tableNames = result.rows.map(row => String(row[0])) + dispatch({ type: 'success', tables: tableNames }) + } + catch (err) { + if (!cancelled) { + dispatch({ type: 'error', error: err instanceof Error ? err : new Error(String(err)) }) + } + } + } + + loadDatabase() + + return () => { + cancelled = true + controller.abort() + void closeDatabase() + } + }, [downloadUrl, closeDatabase]) + + const queryTable = useCallback(async (tableName: string, limit?: number): Promise => { + const client = clientRef.current + const db = dbRef.current + + if (!client || db === null || !tableName) + return null + + if (!state.tables.includes(tableName)) + return null + + const rowLimit = Number.isFinite(limit) && limit && limit > 0 + ? Math.floor(limit) + : DEFAULT_ROW_LIMIT + const cacheKey = `${tableName}:${rowLimit}` + const cached = cacheRef.current.get(cacheKey) + if (cached) + return cached + + const safeName = tableName.replaceAll('"', '""') + const result = await client.sqlite3.execWithParams( + db, + `SELECT * FROM "${safeName}" LIMIT ${rowLimit}`, + [], + ) + const data: SQLiteQueryResult = { + columns: result.columns, + values: result.rows as SQLiteValue[][], + } + cacheRef.current.set(cacheKey, data) + return data + }, [state.tables]) + + return { + tables: state.tables, + isLoading: state.isLoading, + error: state.error, + queryTable, + } +} diff --git a/web/app/components/workflow/skill/viewer/sqlite-file-preview/data-table.tsx b/web/app/components/workflow/skill/viewer/sqlite-file-preview/data-table.tsx new file mode 100644 index 0000000000..8400274dc1 --- /dev/null +++ b/web/app/components/workflow/skill/viewer/sqlite-file-preview/data-table.tsx @@ -0,0 +1,103 @@ +import type { TFunction } from 'i18next' +import type { FC } from 'react' +import type { SQLiteValue } from '../../hooks/use-sqlite-database' +import * as React from 'react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/utils/classnames' + +type DataTableProps = { + columns: string[] + values: SQLiteValue[][] +} + +const MAX_CELL_LENGTH = 120 + +const formatValue = (value: SQLiteValue, t: TFunction<'workflow'>): string => { + if (value === null) + return t('skillSidebar.sqlitePreview.nullValue') + if (value instanceof Uint8Array) + return t('skillSidebar.sqlitePreview.blobValue', { size: value.byteLength }) + if (typeof value === 'bigint') + return value.toString() + return String(value) +} + +const truncateValue = (value: string): string => { + if (value.length <= MAX_CELL_LENGTH) + return value + return `${value.slice(0, MAX_CELL_LENGTH)}...` +} + +const DataTable: FC = ({ columns, values }) => { + const { t } = useTranslation('workflow') + const keyColumnIndex = useMemo(() => { + const candidates = new Set(['id', 'rowid', 'uuid']) + return columns.findIndex(column => candidates.has(column.toLowerCase())) + }, [columns]) + + const rows = useMemo(() => { + return values.map((row) => { + const rowKey = keyColumnIndex >= 0 + ? String(row[keyColumnIndex] ?? '') + : row.map((value) => { + if (value instanceof Uint8Array) + return `blob:${value.byteLength}` + return String(value ?? '') + }).join('|') + + const cells = row.map((value) => { + const rawValue = formatValue(value, t) + return { + rawValue, + displayValue: truncateValue(rawValue), + isNull: value === null, + } + }) + + return { + key: rowKey, + cells, + } + }) + }, [keyColumnIndex, t, values]) + + return ( + + + + {columns.map(column => ( + + ))} + + + + {rows.map(row => ( + + {row.cells.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {column} +
+
+ {cell.displayValue} +
+
+ ) +} + +export default React.memo(DataTable) diff --git a/web/app/components/workflow/skill/viewer/sqlite-file-preview/index.tsx b/web/app/components/workflow/skill/viewer/sqlite-file-preview/index.tsx new file mode 100644 index 0000000000..9478935a63 --- /dev/null +++ b/web/app/components/workflow/skill/viewer/sqlite-file-preview/index.tsx @@ -0,0 +1,187 @@ +import type { FC } from 'react' +import * as React from 'react' +import { useEffect, useMemo, useReducer, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Loading from '@/app/components/base/loading' +import { useSQLiteDatabase } from '../../hooks/use-sqlite-database' +import DataTable from './data-table' +import TableSelector from './table-selector' + +const ROW_LIMIT = 200 + +type SQLiteFilePreviewProps = { + downloadUrl: string +} + +const SQLiteFilePreview: FC = ({ + downloadUrl, +}) => { + const { t } = useTranslation('workflow') + const { tables, isLoading, error, queryTable } = useSQLiteDatabase(downloadUrl) + const [selectedTableId, setSelectedTableId] = useState('') + const [tableState, dispatch] = useReducer(( + current: { + data: Awaited> | null + isLoading: boolean + error: Error | null + }, + action: + | { type: 'reset' } + | { type: 'loading' } + | { type: 'success', data: Awaited> | null } + | { type: 'error', error: Error }, + ) => { + switch (action.type) { + case 'reset': + return { + data: null, + isLoading: false, + error: null, + } + case 'loading': + return { + data: null, + isLoading: true, + error: null, + } + case 'success': + return { + data: action.data, + isLoading: false, + error: null, + } + case 'error': + return { + data: null, + isLoading: false, + error: action.error, + } + default: + return current + } + }, { + data: null, + isLoading: false, + error: null, + }) + + const selectedTable = useMemo(() => { + if (tables.length === 0) + return '' + if (selectedTableId && tables.includes(selectedTableId)) + return selectedTableId + return tables[0] + }, [selectedTableId, tables]) + + useEffect(() => { + if (!selectedTable) { + dispatch({ type: 'reset' }) + return + } + + let cancelled = false + + const loadTable = async () => { + dispatch({ type: 'loading' }) + + try { + const data = await queryTable(selectedTable, ROW_LIMIT) + if (!cancelled) + dispatch({ type: 'success', data }) + } + catch (err) { + if (!cancelled) + dispatch({ type: 'error', error: err instanceof Error ? err : new Error(String(err)) }) + } + } + + loadTable() + + return () => { + cancelled = true + } + }, [queryTable, selectedTable]) + + if (!downloadUrl) { + return ( +
+ + {t('skillEditor.previewUnavailable')} + +
+ ) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+ + {t('skillSidebar.sqlitePreview.loadError')} + +
+ ) + } + + if (tables.length === 0) { + return ( +
+ + {t('skillSidebar.sqlitePreview.emptyTables')} + +
+ ) + } + + return ( +
+
+ +
+
+ {tableState.isLoading + ? ( +
+ +
+ ) + : tableState.error + ? ( +
+ + {t('skillSidebar.sqlitePreview.loadError')} + +
+ ) + : (tableState.data && tableState.data.values.length > 0) + ? ( + + ) + : ( +
+ + {t('skillSidebar.sqlitePreview.emptyRows')} + +
+ )} +
+
+ ) +} + +export default React.memo(SQLiteFilePreview) diff --git a/web/app/components/workflow/skill/viewer/sqlite-file-preview/table-selector.tsx b/web/app/components/workflow/skill/viewer/sqlite-file-preview/table-selector.tsx new file mode 100644 index 0000000000..31204b5325 --- /dev/null +++ b/web/app/components/workflow/skill/viewer/sqlite-file-preview/table-selector.tsx @@ -0,0 +1,98 @@ +import type { FC } from 'react' +import { RiArrowDownSLine } from '@remixicon/react' +import * as React from 'react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Check } from '@/app/components/base/icons/src/vender/line/general' +import { TableCells } from '@/app/components/base/icons/src/vender/solid/development' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import { cn } from '@/utils/classnames' + +type TableSelectorProps = { + tables: string[] + selectedTable: string + isLoading?: boolean + onTableChange: (tableName: string) => void +} + +const TableSelector: FC = ({ + tables, + selectedTable, + isLoading = false, + onTableChange, +}) => { + const { t } = useTranslation('workflow') + const [open, setOpen] = useState(false) + const items = useMemo(() => { + return tables.map(name => ({ + value: name, + name, + })) + }, [tables]) + + const label = selectedTable || t('skillSidebar.sqlitePreview.selectTable') + const isPlaceholder = !selectedTable + + return ( + +
+ + + + +
+ {items.map(item => ( + + ))} +
+
+
+
+ ) +} + +export default React.memo(TableSelector) diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 50b8388f32..703eb7ffad 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1089,6 +1089,12 @@ "skillSidebar.rootFolder": "root folder", "skillSidebar.searchNoResults": "No file were found", "skillSidebar.searchPlaceholder": "Search files…", + "skillSidebar.sqlitePreview.blobValue": "BLOB ({{size}} bytes)", + "skillSidebar.sqlitePreview.emptyRows": "No rows to display", + "skillSidebar.sqlitePreview.emptyTables": "No tables available", + "skillSidebar.sqlitePreview.loadError": "Failed to load SQLite preview", + "skillSidebar.sqlitePreview.nullValue": "NULL", + "skillSidebar.sqlitePreview.selectTable": "Select a table", "skillSidebar.toggleFolder": "Toggle folder", "skillSidebar.unsavedChanges.confirmClose": "Discard", "skillSidebar.unsavedChanges.content": "You have unsaved changes. Do you want to discard them?", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 8cb695481e..c778367baa 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1080,6 +1080,12 @@ "skillSidebar.rootFolder": "根目录", "skillSidebar.searchNoResults": "未找到文件", "skillSidebar.searchPlaceholder": "搜索文件...", + "skillSidebar.sqlitePreview.blobValue": "BLOB({{size}} 字节)", + "skillSidebar.sqlitePreview.emptyRows": "没有可显示的行", + "skillSidebar.sqlitePreview.emptyTables": "没有可用的表", + "skillSidebar.sqlitePreview.loadError": "加载 SQLite 预览失败", + "skillSidebar.sqlitePreview.nullValue": "NULL", + "skillSidebar.sqlitePreview.selectTable": "选择表", "skillSidebar.unsavedChanges.confirmClose": "放弃", "skillSidebar.unsavedChanges.content": "您有未保存的更改,是否放弃?", "skillSidebar.unsavedChanges.title": "未保存的更改",