chore: init monorepo snapshot

This commit is contained in:
liaibo
2025-11-23 10:55:04 +08:00
commit c70ff52869
941 changed files with 246586 additions and 0 deletions
@@ -0,0 +1,259 @@
import { searchHistoryMaxItems, searchHistoryVersion } from '@/lib/constants'
/**
* SearchHistoryManager - Manages search history persistence in localStorage
*
* This utility class handles:
* - Storing and retrieving search history from localStorage
* - Managing history size limits
* - Sorting by access time and frequency
* - Version compatibility
*/
export interface SearchHistoryItem {
label: string // Label name
lastAccessed: number // Last access timestamp
accessCount: number // Access count for sorting optimization
}
export interface SearchHistoryData {
items: SearchHistoryItem[]
version: string // Data version for compatibility
workspace?: string // Workspace isolation (if needed)
}
export class SearchHistoryManager {
private static readonly STORAGE_KEY = 'lightrag_search_history'
private static readonly MAX_HISTORY = searchHistoryMaxItems
private static readonly VERSION = searchHistoryVersion
/**
* Get search history from localStorage
* @returns Array of search history items sorted by last accessed time (descending)
*/
static getHistory(): SearchHistoryItem[] {
try {
const data = localStorage.getItem(this.STORAGE_KEY)
if (!data) return []
const parsed: SearchHistoryData = JSON.parse(data)
// Version compatibility check
if (parsed.version !== this.VERSION) {
console.warn(`Search history version mismatch. Expected ${this.VERSION}, got ${parsed.version}. Clearing history.`)
this.clearHistory()
return []
}
// Ensure items is an array
if (!Array.isArray(parsed.items)) {
console.warn('Invalid search history format. Clearing history.')
this.clearHistory()
return []
}
// Sort by last accessed time (descending) then by access count (descending)
return parsed.items.sort((a, b) => {
if (b.lastAccessed !== a.lastAccessed) {
return b.lastAccessed - a.lastAccessed
}
return (b.accessCount || 0) - (a.accessCount || 0)
})
} catch (error) {
console.error('Error reading search history:', error)
this.clearHistory()
return []
}
}
/**
* Add a label to search history (or update if exists)
* @param label Label to add to history
*/
static addToHistory(label: string): void {
if (!label || typeof label !== 'string' || label.trim() === '') {
return
}
try {
const history = this.getHistory()
const now = Date.now()
const trimmedLabel = label.trim()
// Find existing item
const existingIndex = history.findIndex(item => item.label === trimmedLabel)
if (existingIndex >= 0) {
// Update existing item
const existingItem = history[existingIndex]
existingItem.lastAccessed = now
existingItem.accessCount = (existingItem.accessCount || 0) + 1
// Move to front (will be sorted properly when saved)
history.splice(existingIndex, 1)
history.unshift(existingItem)
} else {
// Add new item to the beginning
history.unshift({
label: trimmedLabel,
lastAccessed: now,
accessCount: 1
})
}
// Limit history size
if (history.length > this.MAX_HISTORY) {
history.splice(this.MAX_HISTORY)
}
// Save to localStorage
const data: SearchHistoryData = {
items: history,
version: this.VERSION
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
} catch (error) {
console.error('Error saving search history:', error)
}
}
/**
* Clear all search history
*/
static clearHistory(): void {
try {
localStorage.removeItem(this.STORAGE_KEY)
} catch (error) {
console.error('Error clearing search history:', error)
}
}
/**
* Initialize history with default popular labels if empty
* @param popularLabels Array of popular labels to use as defaults
*/
static async initializeWithDefaults(popularLabels: string[]): Promise<void> {
const history = this.getHistory()
if (history.length === 0 && popularLabels.length > 0) {
try {
const now = Date.now()
const defaultItems: SearchHistoryItem[] = popularLabels.map((label, index) => ({
label: label.trim(),
lastAccessed: now - index, // Ensure proper ordering
accessCount: 0 // Mark as default/popular items
}))
const data: SearchHistoryData = {
items: defaultItems,
version: this.VERSION
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
} catch (error) {
console.error('Error initializing search history with defaults:', error)
}
}
}
/**
* Get recent searches (items with accessCount > 0)
* @param limit Maximum number of recent searches to return
* @returns Array of recent search items
*/
static getRecentSearches(limit: number = 10): SearchHistoryItem[] {
const history = this.getHistory()
return history
.filter(item => item.accessCount > 0)
.slice(0, limit)
}
/**
* Get popular recommendations (items with accessCount = 0, i.e., defaults)
* @param limit Maximum number of recommendations to return
* @returns Array of popular recommendation items
*/
static getPopularRecommendations(limit?: number): SearchHistoryItem[] {
const history = this.getHistory()
const recommendations = history.filter(item => item.accessCount === 0)
return limit ? recommendations.slice(0, limit) : recommendations
}
/**
* Get all history items as simple string array
* @param limit Maximum number of items to return
* @returns Array of label strings
*/
static getHistoryLabels(limit?: number): string[] {
const history = this.getHistory()
const labels = history.map(item => item.label)
return limit ? labels.slice(0, limit) : labels
}
/**
* Check if a label exists in history
* @param label Label to check
* @returns True if label exists in history
*/
static hasLabel(label: string): boolean {
if (!label || typeof label !== 'string') return false
const history = this.getHistory()
return history.some(item => item.label === label.trim())
}
/**
* Remove a specific label from history
* @param label Label to remove
*/
static removeLabel(label: string): void {
if (!label || typeof label !== 'string') return
try {
const history = this.getHistory()
const trimmedLabel = label.trim()
const filteredHistory = history.filter(item => item.label !== trimmedLabel)
if (filteredHistory.length !== history.length) {
const data: SearchHistoryData = {
items: filteredHistory,
version: this.VERSION
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(data))
}
} catch (error) {
console.error('Error removing label from search history:', error)
}
}
/**
* Get storage statistics
* @returns Object with history statistics
*/
static getStats(): {
totalItems: number
recentSearches: number
popularRecommendations: number
storageSize: number
} {
const history = this.getHistory()
const recentCount = history.filter(item => item.accessCount > 0).length
const popularCount = history.filter(item => item.accessCount === 0).length
let storageSize = 0
try {
const data = localStorage.getItem(this.STORAGE_KEY)
storageSize = data ? data.length : 0
} catch {
// Ignore error
}
return {
totalItems: history.length,
recentSearches: recentCount,
popularRecommendations: popularCount,
storageSize
}
}
}
@@ -0,0 +1,198 @@
/**
* Robust clipboard utility with multiple fallback strategies
* Handles various browser environments and security contexts
*/
export interface CopyResult {
success: boolean;
method: 'clipboard-api' | 'execCommand' | 'manual-select' | 'fallback';
error?: string;
}
/**
* Copy text to clipboard with multiple fallback strategies
* @param text - Text to copy to clipboard
* @returns Promise<CopyResult> - Result object with success status and method used
*/
export async function copyToClipboard(text: string): Promise<CopyResult> {
if (!text || text.trim() === '') {
return {
success: false,
method: 'fallback',
error: 'No text provided'
};
}
// Strategy 1: Modern Clipboard API (preferred)
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(text);
return {
success: true,
method: 'clipboard-api'
};
} catch (error) {
console.warn('Clipboard API failed:', error);
// Continue to fallback methods
}
}
// Strategy 2: Legacy execCommand (for older browsers)
try {
const result = await copyWithExecCommand(text);
if (result.success) {
return result;
}
} catch (error) {
console.warn('execCommand failed:', error);
// Continue to fallback methods
}
// Strategy 3: Manual text selection (most compatible)
try {
const result = await copyWithManualSelection(text);
if (result.success) {
return result;
}
} catch (error) {
console.warn('Manual selection failed:', error);
}
// Strategy 4: Complete fallback - return error
return {
success: false,
method: 'fallback',
error: 'All copy methods failed. Please copy the text manually.'
};
}
/**
* Copy using legacy execCommand method
*/
async function copyWithExecCommand(text: string): Promise<CopyResult> {
return new Promise((resolve) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
textarea.setAttribute('readonly', '');
document.body.appendChild(textarea);
try {
textarea.select();
textarea.setSelectionRange(0, text.length);
const successful = document.execCommand('copy');
if (successful) {
resolve({
success: true,
method: 'execCommand'
});
} else {
resolve({
success: false,
method: 'execCommand',
error: 'execCommand returned false'
});
}
} catch (error) {
resolve({
success: false,
method: 'execCommand',
error: error instanceof Error ? error.message : 'execCommand failed'
});
} finally {
document.body.removeChild(textarea);
}
});
}
/**
* Copy using manual text selection method
*/
async function copyWithManualSelection(text: string): Promise<CopyResult> {
return new Promise((resolve) => {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
textarea.setAttribute('readonly', '');
textarea.setAttribute('tabindex', '-1');
document.body.appendChild(textarea);
try {
// Focus and select the text
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, text.length);
// Try to trigger copy event
const copyEvent = new ClipboardEvent('copy', {
clipboardData: new DataTransfer()
});
if (copyEvent.clipboardData) {
copyEvent.clipboardData.setData('text/plain', text);
document.dispatchEvent(copyEvent);
resolve({
success: true,
method: 'manual-select'
});
} else {
// Fallback: keep text selected for manual copy
resolve({
success: false,
method: 'manual-select',
error: 'Manual selection prepared, but automatic copy failed'
});
}
} catch (error) {
resolve({
success: false,
method: 'manual-select',
error: error instanceof Error ? error.message : 'Manual selection failed'
});
} finally {
// Clean up after a short delay to allow copy operation
setTimeout(() => {
if (document.body.contains(textarea)) {
document.body.removeChild(textarea);
}
}, 100);
}
});
}
/**
* Check if clipboard functionality is available
*/
export function isClipboardSupported(): boolean {
return !!(
(navigator.clipboard && typeof navigator.clipboard.writeText === 'function') ||
typeof document !== 'undefined'
);
}
/**
* Get the best available clipboard method
*/
export function getBestClipboardMethod(): 'clipboard-api' | 'execCommand' | 'manual-select' | 'none' {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
return 'clipboard-api';
}
if (typeof document !== 'undefined') {
return 'execCommand';
}
return 'none';
}
@@ -0,0 +1,228 @@
const DEFAULT_NODE_COLOR = '#5D6D7E'
const TYPE_SYNONYMS: Record<string, string> = {
unknown: 'unknown',
: 'unknown',
other: 'other',
: 'other',
concept: 'concept',
object: 'concept',
type: 'concept',
category: 'concept',
model: 'concept',
project: 'concept',
condition: 'concept',
rule: 'concept',
regulation: 'concept',
article: 'concept',
law: 'concept',
legalclause: 'concept',
policy: 'concept',
disease: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'concept',
: 'policy',
: 'concept',
method: 'method',
process: 'method',
: 'method',
: 'method',
artifact: 'artifact',
technology: 'artifact',
tech: 'artifact',
product: 'artifact',
equipment: 'artifact',
device: 'artifact',
stuff: 'artifact',
component: 'artifact',
material: 'artifact',
chemical: 'artifact',
drug: 'artifact',
medicine: 'artifact',
food: 'artifact',
weapon: 'artifact',
arms: 'artifact',
: 'artifact',
: 'artifact',
: 'technology',
: 'technology',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
: 'artifact',
naturalobject: 'naturalobject',
natural: 'naturalobject',
phenomena: 'naturalobject',
substance: 'naturalobject',
plant: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
: 'naturalobject',
data: 'data',
figure: 'data',
value: 'data',
: 'data',
: 'data',
: 'data',
content: 'content',
book: 'content',
video: 'content',
: 'content',
: 'content',
: 'content',
: 'content',
organization: 'organization',
org: 'organization',
company: 'organization',
: 'organization',
: 'organization',
: 'organization',
: 'organization',
event: 'event',
: 'event',
activity: 'event',
: 'event',
person: 'person',
people: 'person',
human: 'person',
role: 'person',
: 'person',
: 'person',
: 'person',
: 'person',
creature: 'creature',
animal: 'creature',
beings: 'creature',
being: 'creature',
alien: 'creature',
ghost: 'creature',
: 'creature',
: 'creature',
: 'creature',
: 'creature',
: 'creature',
location: 'location',
geography: 'location',
geo: 'location',
place: 'location',
address: 'location',
: 'location',
: 'location',
: 'location',
: 'location',
: 'location'
}
const NODE_TYPE_COLORS: Record<string, string> = {
person: '#4169E1',
creature: '#bd7ebe',
organization: '#00cc00',
location: '#cf6d17',
event: '#00bfa0',
concept: '#e3493b',
method: '#b71c1c',
content: '#0f558a',
data: '#0000ff',
artifact: '#4421af',
naturalobject: '#b2e061',
other: '#f4d371',
unknown: '#b0b0b0'
}
const EXTENDED_COLORS = [
'#84a3e1',
'#5a2c6d',
'#2F4F4F',
'#003366',
'#9b3a31',
'#00CED1',
'#b300b3',
'#0f705d',
'#ff99cc',
'#6ef7b3',
'#cd071e'
]
const PREDEFINED_COLOR_SET = new Set(Object.values(NODE_TYPE_COLORS))
interface ResolveNodeColorResult {
color: string
map: Map<string, string>
updated: boolean
}
export const resolveNodeColor = (
nodeType: string | undefined,
currentMap: Map<string, string> | undefined
): ResolveNodeColorResult => {
const typeColorMap = currentMap ?? new Map<string, string>()
const normalizedType = nodeType ? nodeType.toLowerCase() : 'unknown'
const standardType = TYPE_SYNONYMS[normalizedType]
const cacheKey = standardType || normalizedType
if (typeColorMap.has(cacheKey)) {
return {
color: typeColorMap.get(cacheKey) || DEFAULT_NODE_COLOR,
map: typeColorMap,
updated: false
}
}
if (standardType) {
const color = NODE_TYPE_COLORS[standardType] || DEFAULT_NODE_COLOR
const newMap = new Map(typeColorMap)
newMap.set(standardType, color)
return {
color,
map: newMap,
updated: true
}
}
const usedExtendedColors = new Set(
Array.from(typeColorMap.values()).filter((color) => !PREDEFINED_COLOR_SET.has(color))
)
const unusedColor = EXTENDED_COLORS.find((color) => !usedExtendedColors.has(color))
const color = unusedColor || DEFAULT_NODE_COLOR
const newMap = new Map(typeColorMap)
newMap.set(normalizedType, color)
return {
color,
map: newMap,
updated: true
}
}
export { DEFAULT_NODE_COLOR }
@@ -0,0 +1,65 @@
import { visit } from 'unist-util-visit'
import type { Plugin } from 'unified'
import type { Root, Text } from 'mdast'
// Simple footnote plugin for remark - only renders inline citations
export const remarkFootnotes: Plugin<[], Root> = () => {
return (tree: Root) => {
// Find footnote references and replace them with inline citations
visit(tree, 'text', (node: Text, index, parent) => {
if (!parent || typeof index !== 'number') return
const text = node.value
const footnoteRegex = /\[\^([^\]]+)\]/g
let match
const replacements: any[] = []
let lastIndex = 0
while ((match = footnoteRegex.exec(text)) !== null) {
const [fullMatch, id] = match
const startIndex = match.index!
// Add text before footnote
if (startIndex > lastIndex) {
replacements.push({
type: 'text',
value: text.slice(lastIndex, startIndex)
})
}
// Check if there's another footnote immediately following this one
const nextIndex = startIndex + fullMatch.length
const remainingText = text.slice(nextIndex)
const hasConsecutiveFootnote = /^\[\^[^\]]+\]/.test(remainingText)
// Add footnote reference as HTML with placeholder link
const footnoteHtml = `<sup><a href="#footnote-${id}" class="footnote-ref">${id}</a></sup>`
// Add spacing if there's a consecutive footnote
const htmlWithSpacing = hasConsecutiveFootnote
? footnoteHtml + '&nbsp;'
: footnoteHtml
replacements.push({
type: 'html',
value: htmlWithSpacing
})
lastIndex = startIndex + fullMatch.length
}
// Add remaining text
if (lastIndex < text.length) {
replacements.push({
type: 'text',
value: text.slice(lastIndex)
})
}
// Replace the text node if we found footnotes
if (replacements.length > 1) {
parent.children.splice(index, 1, ...replacements)
}
})
}
}