diff --git a/bridge/src/ai/providers/anthropic.provider.ts b/bridge/src/ai/providers/anthropic.provider.ts index dac87c9..8138a56 100644 --- a/bridge/src/ai/providers/anthropic.provider.ts +++ b/bridge/src/ai/providers/anthropic.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "claude-3-5-haiku-20241022"; export class AnthropicProvider implements AIProvider { private client: Anthropic; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.client = new Anthropic({ apiKey }); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const msg = await this.client.messages.create({ - model: DEFAULT_MODEL, + model: this.model, max_tokens: 4096, system, messages: [{ role: "user", content: user }], @@ -53,7 +55,7 @@ export class AnthropicProvider implements AIProvider { async testConnection(): Promise { try { await this.client.messages.create({ - model: DEFAULT_MODEL, + model: this.model, max_tokens: 10, messages: [{ role: "user", content: "ping" }], }); diff --git a/bridge/src/ai/providers/gemini.provider.ts b/bridge/src/ai/providers/gemini.provider.ts index 8de2bc8..e44e0d1 100644 --- a/bridge/src/ai/providers/gemini.provider.ts +++ b/bridge/src/ai/providers/gemini.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "gemini-1.5-flash"; export class GeminiProvider implements AIProvider { private genAI: GoogleGenerativeAI; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.genAI = new GoogleGenerativeAI(apiKey); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const model = this.genAI.getGenerativeModel({ - model: DEFAULT_MODEL, + model: this.model, systemInstruction: system, generationConfig: { maxOutputTokens: 4096 }, }); @@ -51,7 +53,7 @@ export class GeminiProvider implements AIProvider { async testConnection(): Promise { try { - const model = this.genAI.getGenerativeModel({ model: DEFAULT_MODEL }); + const model = this.genAI.getGenerativeModel({ model: this.model }); await model.generateContent("ping"); return ""; } catch (err) { diff --git a/bridge/src/ai/providers/groq.provider.ts b/bridge/src/ai/providers/groq.provider.ts index 4b840d7..2e6c289 100644 --- a/bridge/src/ai/providers/groq.provider.ts +++ b/bridge/src/ai/providers/groq.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "llama-3.3-70b-versatile"; export class GroqProvider implements AIProvider { private client: Groq; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.client = new Groq({ apiKey }); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const res = await this.client.chat.completions.create({ - model: DEFAULT_MODEL, + model: this.model, messages: [ { role: "system", content: system }, { role: "user", content: user }, @@ -54,7 +56,7 @@ export class GroqProvider implements AIProvider { async testConnection(): Promise { try { await this.client.chat.completions.create({ - model: DEFAULT_MODEL, + model: this.model, messages: [{ role: "user", content: "ping" }], max_tokens: 5, }); diff --git a/bridge/src/ai/providers/mistral.provider.ts b/bridge/src/ai/providers/mistral.provider.ts index abb74ee..fa8ecb4 100644 --- a/bridge/src/ai/providers/mistral.provider.ts +++ b/bridge/src/ai/providers/mistral.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "mistral-small-latest"; export class MistralProvider implements AIProvider { private client: Mistral; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.client = new Mistral({ apiKey }); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const res = await this.client.chat.complete({ - model: DEFAULT_MODEL, + model: this.model, messages: [ { role: "system", content: system }, { role: "user", content: user }, @@ -60,7 +62,7 @@ export class MistralProvider implements AIProvider { async testConnection(): Promise { try { await this.client.chat.complete({ - model: DEFAULT_MODEL, + model: this.model, messages: [{ role: "user", content: "ping" }], maxTokens: 5, }); diff --git a/bridge/src/ai/providers/openai.provider.ts b/bridge/src/ai/providers/openai.provider.ts index 1c9e841..d5d0291 100644 --- a/bridge/src/ai/providers/openai.provider.ts +++ b/bridge/src/ai/providers/openai.provider.ts @@ -14,15 +14,17 @@ const DEFAULT_MODEL = "gpt-4o-mini"; export class OpenAIProvider implements AIProvider { private client: OpenAI; + private model: string; - constructor(apiKey: string) { + constructor(apiKey: string, model?: string) { this.client = new OpenAI({ apiKey }); + this.model = model?.trim() || DEFAULT_MODEL; } private async complete(system: string, user: string): Promise { try { const res = await this.client.chat.completions.create({ - model: DEFAULT_MODEL, + model: this.model, messages: [ { role: "system", content: system }, { role: "user", content: user }, @@ -54,7 +56,7 @@ export class OpenAIProvider implements AIProvider { async testConnection(): Promise { try { await this.client.chat.completions.create({ - model: DEFAULT_MODEL, + model: this.model, messages: [{ role: "user", content: "ping" }], max_tokens: 5, }); diff --git a/bridge/src/handlers/aiHandlers.ts b/bridge/src/handlers/aiHandlers.ts index 49201b4..9f127b3 100644 --- a/bridge/src/handlers/aiHandlers.ts +++ b/bridge/src/handlers/aiHandlers.ts @@ -7,6 +7,7 @@ import { AIExplainQueryParams, AIRecommendChartParams, AITestConnectionParams, + AISettings, } from "../types/ai"; import { getOrCall, @@ -19,6 +20,9 @@ import { buildSchemaAnalysisPrompt } from "../ai/prompts/schema-analysis"; import { buildQueryExplanationPrompt } from "../ai/prompts/query-explanation"; import { buildChartRecommendationPrompt } from "../ai/prompts/chart-recommendation"; import { parseChartRecommendation } from "../ai/prompts/chart-recommendation"; +import fs from "fs/promises"; +import fsSync from "fs"; +import { AI_SETTINGS_FILE, CONFIG_FOLDER, ensureDir } from "../utils/config"; export class AIHandlers { private aiService: AIService; @@ -219,4 +223,43 @@ export class AIHandlers { this.rpc.sendError(id, { code: "HISTORY_ERROR", message: err?.message ?? String(err) }); } } + + // ── Settings persistence (reads/writes ai-settings.json) ────────────── + + async handleLoadSettings(_params: unknown, id: number | string) { + try { + ensureDir(CONFIG_FOLDER); + if (!fsSync.existsSync(AI_SETTINGS_FILE)) { + // Return empty object — frontend will fall back to defaults + this.rpc.sendResponse(id, { ok: true, data: {} }); + return; + } + const raw = await fs.readFile(AI_SETTINGS_FILE, "utf-8"); + const settings = JSON.parse(raw) as AISettings; + this.rpc.sendResponse(id, { ok: true, data: settings }); + } catch (err: any) { + this.logger?.warn({ err }, "ai.loadSettings failed — returning empty"); + // Non-fatal: return empty so the app still starts + this.rpc.sendResponse(id, { ok: true, data: {} }); + } + } + + async handleSaveSettings(params: { settings: AISettings }, id: number | string) { + try { + ensureDir(CONFIG_FOLDER); + await fs.writeFile( + AI_SETTINGS_FILE, + JSON.stringify(params.settings, null, 2), + "utf-8" + ); + // On non-Windows platforms, restrict file permissions (contains API keys) + if (process.platform !== "win32") { + await fs.chmod(AI_SETTINGS_FILE, 0o600); + } + this.rpc.sendResponse(id, { ok: true, data: { saved: true } }); + } catch (err: any) { + this.logger?.error({ err }, "ai.saveSettings failed"); + this.rpc.sendError(id, { code: "SAVE_ERROR", message: err?.message ?? String(err) }); + } + } } diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts index e6604c5..b13c668 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -389,6 +389,12 @@ export function registerDbHandlers( rpcRegister(rpc, "ai.clearHistory", (p, id) => aiHandlers.handleClearHistory(p, id) ); + rpcRegister(rpc, "ai.loadSettings", (p, id) => + aiHandlers.handleLoadSettings(p, id) + ); + rpcRegister(rpc, "ai.saveSettings", (p, id) => + aiHandlers.handleSaveSettings(p, id) + ); logger?.info("All RPC handlers registered successfully"); } diff --git a/bridge/src/services/ai.impl.ts b/bridge/src/services/ai.impl.ts index 3554216..4ede9f4 100644 --- a/bridge/src/services/ai.impl.ts +++ b/bridge/src/services/ai.impl.ts @@ -25,27 +25,27 @@ export class AIServiceImpl { case "anthropic": { const key = settings.anthropicApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "anthropic", "Anthropic API key is not configured."); - return new AnthropicProvider(key); + return new AnthropicProvider(key, settings.anthropicModel); } case "openai": { const key = settings.openaiApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "openai", "OpenAI API key is not configured."); - return new OpenAIProvider(key); + return new OpenAIProvider(key, settings.openaiModel); } case "gemini": { const key = settings.geminiApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "gemini", "Gemini API key is not configured."); - return new GeminiProvider(key); + return new GeminiProvider(key, settings.geminiModel); } case "groq": { const key = settings.groqApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "groq", "Groq API key is not configured."); - return new GroqProvider(key); + return new GroqProvider(key, settings.groqModel); } case "mistral": { const key = settings.mistralApiKey?.trim(); if (!key) throw new AIError("MISSING_API_KEY", "mistral", "Mistral API key is not configured."); - return new MistralProvider(key); + return new MistralProvider(key, settings.mistralModel); } case "ollama": { return new OllamaProvider(settings.ollamaBaseUrl, settings.ollamaModel); diff --git a/bridge/src/services/aiCacheService.ts b/bridge/src/services/aiCacheService.ts index 6294668..888335a 100644 --- a/bridge/src/services/aiCacheService.ts +++ b/bridge/src/services/aiCacheService.ts @@ -98,15 +98,23 @@ export function hashChartRecommendation(input: ChartRecommendationInput, datasou /** * Resolve the model name from the settings based on provider. - * This is best-effort — some providers don't expose the model in settings. */ function resolveModelName(settings: AISettings): string { - const provider = settings.defaultProvider; - switch (provider) { + switch (settings.defaultProvider) { + case "anthropic": + return settings.anthropicModel ?? "claude-3-5-haiku-20241022"; + case "openai": + return settings.openaiModel ?? "gpt-4o-mini"; + case "gemini": + return settings.geminiModel ?? "gemini-1.5-flash"; + case "groq": + return settings.groqModel ?? "llama-3.3-70b-versatile"; + case "mistral": + return settings.mistralModel ?? "mistral-small-latest"; case "ollama": - return settings.ollamaModel ?? "ollama-default"; + return settings.ollamaModel ?? "llama3.2"; default: - return provider; // For API-key providers, the model is selected by the SDK + return settings.defaultProvider; } } diff --git a/bridge/src/types/ai.ts b/bridge/src/types/ai.ts index 7246326..1cdd778 100644 --- a/bridge/src/types/ai.ts +++ b/bridge/src/types/ai.ts @@ -21,6 +21,12 @@ export interface AISettings { mistralApiKey?: string; ollamaBaseUrl?: string; ollamaModel?: string; + // Per-provider selected model (overrides provider default) + anthropicModel?: string; + openaiModel?: string; + geminiModel?: string; + groqModel?: string; + mistralModel?: string; } // ── Feature input/output types ──────────────────────────────────────────── diff --git a/bridge/src/utils/config.ts b/bridge/src/utils/config.ts index f650257..998f5ed 100644 --- a/bridge/src/utils/config.ts +++ b/bridge/src/utils/config.ts @@ -13,6 +13,7 @@ export const CONFIG_FOLDER = export const CONFIG_FILE = path.join(CONFIG_FOLDER, "databases.json"); export const CREDENTIALS_FILE = path.join(CONFIG_FOLDER, ".credentials"); +export const AI_SETTINGS_FILE = path.join(CONFIG_FOLDER, "ai-settings.json"); export const PROJECTS_FOLDER = path.join(CONFIG_FOLDER, "projects"); diff --git a/src/components/shared/DataTable.tsx b/src/components/shared/DataTable.tsx index e64786b..9daab30 100644 --- a/src/components/shared/DataTable.tsx +++ b/src/components/shared/DataTable.tsx @@ -9,6 +9,7 @@ import { import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { Database, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { formatTimestamp } from "@/lib/utils"; interface DataTableProps { data: Array>; @@ -84,7 +85,7 @@ export const DataTable = ({ title={row[column]?.toString() || 'NULL'} > {row[column] !== null && row[column] !== undefined ? ( - formatCellValue(row[column]) + formatCellValue(row[column], column) ) : ( null )} @@ -146,7 +147,7 @@ export const DataTable = ({ }; // Helper function to format cell values with improved styling -function formatCellValue(value: any): React.ReactNode { +function formatCellValue(value: any, columnName?: string): React.ReactNode { if (value === null || value === undefined) { return null; } @@ -163,6 +164,14 @@ function formatCellValue(value: any): React.ReactNode { } if (typeof value === 'number') { + const isLikelyTimestampCol = columnName?.toLowerCase().match(/time|date|created|updated|deleted/); + if (isLikelyTimestampCol) { + if (value > 1e9 && value < 1e10) { // Seconds + return {formatTimestamp(new Date(value * 1000).toISOString())}; + } else if (value > 1e12 && value < 1e13) { // Milliseconds + return {formatTimestamp(new Date(value).toISOString())}; + } + } return ( {value.toLocaleString()} @@ -173,7 +182,7 @@ function formatCellValue(value: any): React.ReactNode { if (value instanceof Date) { return ( - {value.toLocaleString()} + {formatTimestamp(value.toISOString())} ); } @@ -195,8 +204,20 @@ function formatCellValue(value: any): React.ReactNode { const strValue = String(value); // Check if it looks like a date string - if (/^\d{4}-\d{2}-\d{2}/.test(strValue)) { - return {strValue}; + if (typeof strValue === 'string' && /^\d{4}-\d{2}-\d{2}/.test(strValue)) { + return {formatTimestamp(strValue)}; + } + + // Check if it's a UNIX timestamp (seconds or ms) based on column name heuristic + const isLikelyTimestampCol = columnName?.toLowerCase().match(/time|date|created|updated|deleted/); + if (isLikelyTimestampCol) { + // 10 digits (seconds) or 13 digits (ms) + if (/^\d{10}$/.test(strValue)) { + return {formatTimestamp(new Date(Number(strValue) * 1000).toISOString())}; + } + if (/^\d{13}$/.test(strValue)) { + return {formatTimestamp(new Date(Number(strValue)).toISOString())}; + } } // Check if it looks like an ID or UUID diff --git a/src/features/ai/hooks/useAISettings.ts b/src/features/ai/hooks/useAISettings.ts index 92045d3..bf8c8b0 100644 --- a/src/features/ai/hooks/useAISettings.ts +++ b/src/features/ai/hooks/useAISettings.ts @@ -1,22 +1,30 @@ import { loadAISettings, type AISettings } from "@/services/bridge/ai"; import { useEffect, useState } from "react"; +const DEFAULT: AISettings = { defaultProvider: "openai" }; + /** - * Reads AISettings from localStorage and stays in sync when - * the user updates them in the Settings page during the same session. + * Reads AISettings from the bridge (ai-settings.json on disk). + * Returns a stable object that is refreshed whenever the settings dialog saves. + * + * Because saving goes through the bridge and NOT localStorage, the old + * StorageEvent trick no longer applies. Instead, callers that need fresh + * settings after a save should re-mount or call `reload()`. */ -export function useAISettings(): AISettings { - const [settings, setSettings] = useState(loadAISettings); +export function useAISettings(): { settings: AISettings; isLoading: boolean; reload: () => void } { + const [settings, setSettings] = useState(DEFAULT); + const [isLoading, setIsLoading] = useState(true); + const [tick, setTick] = useState(0); useEffect(() => { - const onStorage = (e: StorageEvent) => { - if (e.key === "relwave:ai-settings") { - setSettings(loadAISettings()); - } - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); + setIsLoading(true); + loadAISettings().then((loaded) => { + setSettings(loaded); + setIsLoading(false); + }); + }, [tick]); + + const reload = () => setTick((t) => t + 1); - return settings; + return { settings, isLoading, reload }; } diff --git a/src/features/chart/components/ChartVisualization.tsx b/src/features/chart/components/ChartVisualization.tsx index f470f4f..f490434 100644 --- a/src/features/chart/components/ChartVisualization.tsx +++ b/src/features/chart/components/ChartVisualization.tsx @@ -34,7 +34,7 @@ export const ChartVisualization = ({ selectedTable, dbId, }: ChartVisualizationProps) => { - const aiSettings = useAISettings(); + const { settings: aiSettings } = useAISettings(); const [aiLoading, setAiLoading] = useState(false); const { diff --git a/src/features/database/components/MigrationsPanel.tsx b/src/features/database/components/MigrationsPanel.tsx index 2058b69..5f5bba8 100644 --- a/src/features/database/components/MigrationsPanel.tsx +++ b/src/features/database/components/MigrationsPanel.tsx @@ -7,6 +7,7 @@ import { MigrationsData } from "@/features/database/types"; import { useMigrationsPanel } from "../hooks/useMigrationsPanel"; import { cn } from "@/lib/utils"; import { formatTimestamp } from "@/lib/utils"; +import { MigrationsPanelLoadingState } from "@/features/database/components/MigrationsPanelLoadingState"; interface MigrationsPanelProps { migrations: MigrationsData; @@ -32,6 +33,12 @@ export default function MigrationsPanel({ migrations, baselined, dbId }: Migrati setShowSQLDialog, } = useMigrationsPanel({ migrations, baselined, dbId }) + if (isRefreshing) { + return ( + + ); + } + return ( <> diff --git a/src/features/database/components/MigrationsPanelLoadingState.tsx b/src/features/database/components/MigrationsPanelLoadingState.tsx new file mode 100644 index 0000000..1870597 --- /dev/null +++ b/src/features/database/components/MigrationsPanelLoadingState.tsx @@ -0,0 +1,56 @@ +import { Card, CardHeader, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function MigrationsPanelLoadingState() { + return ( +
+ + +
+ + +
+
+ + +
+
+ +
+ {["Applied", "Pending"].map((_, i) => ( + + + + + ))} +
+
+ {[ + { status: "applied", w: "w-40" }, + { status: "applied", w: "w-52" }, + { status: "applied", w: "w-36" }, + { status: "pending", w: "w-48" }, + { status: "pending", w: "w-44" }, + ].map((row, i) => ( +
+
+ + + +
+ +
+ + +
+
+ ))} +
+
+
+
+ ) +} diff --git a/src/features/er-diagram/components/ERDiagramContent.tsx b/src/features/er-diagram/components/ERDiagramContent.tsx index c3c2238..a8622db 100644 --- a/src/features/er-diagram/components/ERDiagramContent.tsx +++ b/src/features/er-diagram/components/ERDiagramContent.tsx @@ -35,6 +35,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { projectService } from "@/services/bridge/project"; +import { ERDiagramLoadingState } from "@/features/er-diagram/components/ERDiagramLoadingState"; const AnnotationLayer = lazy(() => import("@/features/er-diagram/components/AnnotationLayer")); @@ -367,9 +368,7 @@ const ERDiagramContent: React.FC = ({ nodeTypes, projectI // --- Conditional rendering --- if (isLoading) { return ( -
- -
+ ); } diff --git a/src/features/er-diagram/components/ERDiagramLoadingState.tsx b/src/features/er-diagram/components/ERDiagramLoadingState.tsx new file mode 100644 index 0000000..3c8c71b --- /dev/null +++ b/src/features/er-diagram/components/ERDiagramLoadingState.tsx @@ -0,0 +1,59 @@ + + +export function ERDiagramLoadingState() { + return ( +
+ {/* Toolbar skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Canvas with ghost table nodes */} +
+ {[ + { top: "12%", left: "8%", cols: 5 }, + { top: "18%", left: "38%", cols: 4 }, + { top: "10%", left: "65%", cols: 6 }, + { top: "52%", left: "22%", cols: 3 }, + { top: "55%", left: "58%", cols: 5 }, + ].map((node, i) => ( +
+
+
+
+
+ {Array.from({ length: node.cols }).map((_, j) => ( +
+
+
+
+
+ ))} +
+
+ ))} + {/* MiniMap ghost */} +
+ {/* Controls ghost */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+
+
+ ) +} diff --git a/src/features/er-diagram/components/TableNode.tsx b/src/features/er-diagram/components/TableNode.tsx index 36fbae6..13632aa 100644 --- a/src/features/er-diagram/components/TableNode.tsx +++ b/src/features/er-diagram/components/TableNode.tsx @@ -72,28 +72,32 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return ( -
+
{/* Header */} -
- - - {data.label} - - {data.schema} - +
+
+ + + {data.label} +
+ {data.schema !== 'public' && ( + + {data.schema} + + )}
{/* Columns */} -
+
{visibleColumns.map((col) => { const isUnique = uniqueColumns.has(col.name); const isIndexed = indexedColumns.has(col.name); @@ -132,7 +136,7 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* Left handle for incoming connections (target) - for PK/unique columns */} {(isPkTarget || isUnique) && ( @@ -140,8 +144,8 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { type="target" position={Position.Left} id={handleId} - className="w-2! h-2! bg-amber-500! border-amber-600!" - style={{ top: '50%' }} + className="w-1! h-1! opacity-0 pointer-events-none" + style={{ left: '-8px' }} /> )} @@ -151,43 +155,31 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { type="source" position={Position.Right} id={handleId} - className="w-2! h-2! bg-cyan-500! border-cyan-600!" - style={{ top: '50%' }} + className="w-1! h-1! opacity-0 pointer-events-none" + style={{ right: '-8px' }} /> )} -
- {col.isPrimaryKey && ( - +
+ {col.isPrimaryKey ? ( + + ) : col.isForeignKey ? ( + + ) : ( + )} - + + {col.name} - {isUnique && ( - - UQ - - )} - {isIndexed && ( - - IDX - - )} -
-
- {col.type} - {col.isForeignKey && ( - - )} + {!col.nullable && ( - * + * )}
+
+ {col.type} +
@@ -196,79 +188,14 @@ const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { ); })} -
- {/* Collapsed indicator */} - {isCollapsed && data.columns.length > visibleColumns.length && ( -
- +{data.columns.length - visibleColumns.length} more columns -
- )} - - {/* Footer with constraint counts */} - {(data.foreignKeys?.length || data.indexes?.length || data.checkConstraints?.length) ? ( -
- {data.foreignKeys && data.foreignKeys.length > 0 && ( - - - - {data.foreignKeys.length} FK - - - -
-
Foreign Keys
- {data.foreignKeys.map(fk => ( -
- {fk.source_column} → {fk.target_table}.{fk.target_column} -
- ))} -
-
-
- )} - {data.indexes && data.indexes.length > 0 && ( - - - - {data.indexes.length} IDX - - - -
-
Indexes
- {data.indexes.map(idx => ( -
- {idx.index_name} ({idx.column_name}) - {idx.is_unique && " • Unique"} -
- ))} -
-
-
- )} - {data.checkConstraints && data.checkConstraints.length > 0 && ( - - - - {data.checkConstraints.length} CHK - - - -
-
Check Constraints
- {data.checkConstraints.map(chk => ( -
- {chk.constraint_name}: {chk.definition} -
- ))} -
-
-
- )} - {data.columns.length} cols -
- ) : null} + {/* Collapsed indicator */} + {isCollapsed && data.columns.length > visibleColumns.length && ( +
+ +{data.columns.length - visibleColumns.length} more columns +
+ )} +
); diff --git a/src/features/git/components/GitStatusPanel.tsx b/src/features/git/components/GitStatusPanel.tsx index 8108c12..0b488e0 100644 --- a/src/features/git/components/GitStatusPanel.tsx +++ b/src/features/git/components/GitStatusPanel.tsx @@ -45,6 +45,7 @@ import type { GitFileChange, GitLogEntry } from "@/features/git/types"; import { gitService } from "@/services/bridge/git"; import { projectService } from "@/services/bridge/project"; import { GitHistoryGraph } from "./GitHistoryGraph"; +import { GitStatusPanelLoadingState } from "./GitStatusPanelLoadingState"; // ─── Helpers ────────────────────────────────────────── @@ -136,12 +137,9 @@ export default function GitStatusPanel({ projectDir, projectId }: GitStatusPanel ); } - if (statusLoading) { + if (statusLoading && projectDir) { return ( -
- - Loading git status… -
+ ); } diff --git a/src/features/git/components/GitStatusPanelLoadingState.tsx b/src/features/git/components/GitStatusPanelLoadingState.tsx new file mode 100644 index 0000000..94bf83b --- /dev/null +++ b/src/features/git/components/GitStatusPanelLoadingState.tsx @@ -0,0 +1,70 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function GitStatusPanelLoadingState() { + return ( +
+ {/* Header */} +
+
+ + + +
+
+ + +
+
+ {/* Tab bar */} +
+ {["w-16", "w-20", "w-14"].map((w, i) => ( + + ))} +
+ {/* Content */} +
+ {/* Branch badge */} +
+ + + +
+ {/* Staged section */} +
+
+ + + +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ + + +
+ ))} +
+ {/* Unstaged section */} +
+
+ + + +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ {/* Commit area */} +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/src/features/monitoring/components/MetricCard.tsx b/src/features/monitoring/components/MetricCard.tsx new file mode 100644 index 0000000..bdaa531 --- /dev/null +++ b/src/features/monitoring/components/MetricCard.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { Activity, Clock3, TimerReset } from "lucide-react"; + +export function MetricCard({ + icon: Icon, + label, + value, + detail, + valueClassName, + children, +}: { + icon: typeof Activity; + label: string; + value: string; + detail: string; + valueClassName?: string; + children?: React.ReactNode; +}) { + return ( + + +
+ {label} + + {value} + +
+
+ +
+
+ +
+ {label === "Throughput" ? : } + {detail} +
+ {children} +
+
+ ); +} diff --git a/src/features/monitoring/components/MonitoringDashboard.tsx b/src/features/monitoring/components/MonitoringDashboard.tsx new file mode 100644 index 0000000..2dae588 --- /dev/null +++ b/src/features/monitoring/components/MonitoringDashboard.tsx @@ -0,0 +1,173 @@ +import { + CircleAlert, + DatabaseZap, + Gauge, + Server, + ShieldCheck, +} from "lucide-react"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { MonitoringSnapshot } from "@/features/database/types"; +import { MetricCard } from "./MetricCard"; +import { formatNumber } from "@/lib/utils"; +import { formatDuration } from "@/lib/utils"; + + +type ThroughputPoint = { + time: string; + qps: number; + connections: number; +}; + +export function MonitoringDashboard({ + data, + history, + statusTone, +}: { + data: MonitoringSnapshot; + history: ThroughputPoint[]; + statusTone: string; +}) { + return ( +
+ {!data.health.ok && ( + + + Database ping failed + {data.health.message || "The database did not respond to SELECT 1."} + + )} + +
+ + + + + + +
+ +
+ + +
+
+ Throughput Timeline + Queries per second and active connections +
+ + {history.length} samples + +
+
+ + + + + + + + + + + + +
+ + + + Active Queries + Non-idle requests currently reported by the database + + + + + + + Time + User + State + Query + + + + {data.activeQueries.length === 0 ? ( + + + No active database requests + + + ) : ( + data.activeQueries.map((query) => ( + + + {formatDuration(query.durationSeconds)} + + {query.user || "-"} + + + {query.state || "active"} + + + + {query.query || "-"} + + + )) + )} + +
+
+
+
+
+
+ ); +} diff --git a/src/features/monitoring/components/MonitoringLoadingState.tsx b/src/features/monitoring/components/MonitoringLoadingState.tsx new file mode 100644 index 0000000..4b220eb --- /dev/null +++ b/src/features/monitoring/components/MonitoringLoadingState.tsx @@ -0,0 +1,68 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; + +export function MonitoringLoadingState() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, index) => ( + + +
+ + +
+
+ +
+
+ + + {index === 1 ? : null} + +
+ ))} +
+ +
+ + +
+
+ + +
+ +
+
+ +
+ +

Connecting to live monitoring stream

+
+
+
+ + + +
+ + +
+
+ + {Array.from({ length: 5 }).map((_, index) => ( +
+ + + + +
+ ))} +
+
+
+
+ ); +} diff --git a/src/features/monitoring/components/MonitoringPanel.tsx b/src/features/monitoring/components/MonitoringPanel.tsx index d4c7a4d..2f88b51 100644 --- a/src/features/monitoring/components/MonitoringPanel.tsx +++ b/src/features/monitoring/components/MonitoringPanel.tsx @@ -1,43 +1,17 @@ -import type * as React from "react"; import { useEffect, useMemo, useState } from "react"; import { Activity, CircleAlert, - Clock3, - DatabaseZap, - Gauge, RefreshCw, - Server, - ShieldCheck, - TimerReset, } from "lucide-react"; -import { - CartesianGrid, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Progress } from "@/components/ui/progress"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; import { Spinner } from "@/components/ui/spinner"; -import { MonitoringSnapshot } from "@/features/database/types"; import { useMonitoringStream } from "@/features/monitoring/hooks/useMonitoringStream"; -import { cn } from "@/lib/utils"; +import { formatDbType } from "@/lib/utils"; +import { MonitoringDashboard } from "./MonitoringDashboard"; +import { MonitoringLoadingState } from "./MonitoringLoadingState"; interface MonitoringPanelProps { dbId: string; @@ -56,24 +30,7 @@ function isMonitoringSupported(databaseType?: string) { return type === "postgres" || type === "postgresql" || type === "mysql" || type === "mariadb"; } -function formatNumber(value: number) { - return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value); -} - -function formatDuration(seconds: number) { - if (seconds < 60) return `${Math.round(seconds)}s`; - const minutes = Math.floor(seconds / 60); - const remaining = Math.round(seconds % 60); - return `${minutes}m ${remaining}s`; -} -function formatDbType(databaseType?: string) { - if (!databaseType) return "Database"; - if (databaseType === "postgres" || databaseType === "postgresql") return "PostgreSQL"; - if (databaseType === "mysql") return "MySQL"; - if (databaseType === "mariadb") return "MariaDB"; - return databaseType; -} export function MonitoringPanel({ dbId, databaseName, databaseType }: MonitoringPanelProps) { const supported = isMonitoringSupported(databaseType); @@ -181,240 +138,3 @@ export function MonitoringPanel({ dbId, databaseName, databaseType }: Monitoring
); } - -function MonitoringLoadingState() { - return ( -
-
- {Array.from({ length: 4 }).map((_, index) => ( - - -
-
-
-
-
-
-
- - -
- {index === 1 ?
: null} - - - ))} -
- -
- - -
-
-
-
-
-
-
- - -
- -

Connecting to live monitoring stream

-
-
- - - - -
-
-
-
- - - {Array.from({ length: 5 }).map((_, index) => ( -
-
-
-
-
-
- ))} - - -
-
- ); -} - -function MonitoringDashboard({ - data, - history, - statusTone, -}: { - data: MonitoringSnapshot; - history: ThroughputPoint[]; - statusTone: string; -}) { - return ( -
- {!data.health.ok && ( - - - Database ping failed - {data.health.message || "The database did not respond to SELECT 1."} - - )} - -
- - - - - - -
- -
- - -
-
- Throughput Timeline - Queries per second and active connections -
- - {history.length} samples - -
-
- - - - - - - - - - - - -
- - - - Active Queries - Non-idle requests currently reported by the database - - - - - - - Time - User - State - Query - - - - {data.activeQueries.length === 0 ? ( - - - No active database requests - - - ) : ( - data.activeQueries.map((query) => ( - - - {formatDuration(query.durationSeconds)} - - {query.user || "-"} - - - {query.state || "active"} - - - - {query.query || "-"} - - - )) - )} - -
-
-
-
-
-
- ); -} - -function MetricCard({ - icon: Icon, - label, - value, - detail, - valueClassName, - children, -}: { - icon: typeof Activity; - label: string; - value: string; - detail: string; - valueClassName?: string; - children?: React.ReactNode; -}) { - return ( - - -
- {label} - - {value} - -
-
- -
-
- -
- {label === "Throughput" ? : } - {detail} -
- {children} -
-
- ); -} diff --git a/src/features/query-builder/components/QueryBuilderPanel.tsx b/src/features/query-builder/components/QueryBuilderPanel.tsx index 0da1eaa..cc390c5 100644 --- a/src/features/query-builder/components/QueryBuilderPanel.tsx +++ b/src/features/query-builder/components/QueryBuilderPanel.tsx @@ -11,7 +11,6 @@ import { toast } from "sonner"; import { useFullSchema } from "@/features/project/hooks/useDbQueries"; import { useQueryHistory } from "@/features/query-builder/hooks/useQueryHistory"; import { useDatabase } from "@/features/project/hooks/useDbQueries"; -import { Spinner } from "@/components/ui/spinner"; import { useBridgeQuery } from "@/services/bridge/useBridgeQuery"; import { TableRow } from "@/features/database/types"; import { BuilderHeader } from "./BuilderHeader"; @@ -22,6 +21,7 @@ import { BuilderStatusBar } from "./BuilderStatusBar"; import { QueryFilter, ColumnOption } from "../types"; import { sessionService } from "@/services/bridge/session"; import { queryService } from "@/services/bridge/query"; +import { QueryBuilderPanelLoadingState } from "./QueryBuilderPanelLoadingState"; interface QueryBuilderPanelProps { dbId: string; @@ -349,9 +349,7 @@ const QueryBuilderPanel = ({ dbId }: QueryBuilderPanelProps) => { if (bridgeLoading || bridgeReady === undefined || loading) { return ( -
- -
+ ); } diff --git a/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx b/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx new file mode 100644 index 0000000..3d80fd0 --- /dev/null +++ b/src/features/query-builder/components/QueryBuilderPanelLoadingState.tsx @@ -0,0 +1,79 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card } from "@/components/ui/card"; + +export function QueryBuilderPanelLoadingState() { + return ( +
+ {/* Header */} +
+
+ + +
+
+ + +
+
+ {/* Body */} +
+ {/* Sidebar skeleton */} + +
+ + {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ {/* Main canvas + results */} + + {/* Canvas area (diagram) */} +
+ {/* Fake node cards */} + {[{ top: "20%", left: "15%" }, { top: "25%", left: "55%" }, { top: "60%", left: "35%" }].map((pos, i) => ( + +
+ +
+
+ {Array.from({ length: 3 }).map((_, j) => ( +
+ + +
+ ))} +
+
+ ))} +
+ {/* SQL results pane */} +
+ + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+
+ {/* Status bar */} +
+ + +
+
+ ) +} diff --git a/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx index acb8289..68161fb 100644 --- a/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx +++ b/src/features/schema-explorer/components/AnalyzeSchemaButton.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Bot, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { AIResultDialog } from "@/features/ai/components/AIResultDialog"; @@ -23,10 +23,11 @@ interface AnalyzeSchemaButtonProps { }>; }; databaseType?: string; + dbId?: string; } -export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaButtonProps) { - const settings = useAISettings(); +export function AnalyzeSchemaButton({ schemaData, databaseType, dbId }: AnalyzeSchemaButtonProps) { + const { settings } = useAISettings(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [markdown, setMarkdown] = useState(); @@ -34,8 +35,23 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB const [cached, setCached] = useState(); const [createdAt, setCreatedAt] = useState(); + // Track which datasource the cached markdown belongs to + const cachedForRef = useRef(undefined); + const tableCount = schemaData.schemas?.flatMap((s) => s.tables).length ?? 0; + // Reset all local state when the user switches to a different database + useEffect(() => { + const identifier = dbId ?? schemaData.name; + if (cachedForRef.current !== undefined && cachedForRef.current !== identifier) { + setMarkdown(undefined); + setError(null); + setCached(undefined); + setCreatedAt(undefined); + cachedForRef.current = undefined; + } + }, [schemaData.name, dbId]); + const buildInput = (): SchemaAnalysisInput => ({ databaseType, tables: schemaData.schemas.flatMap((schema) => @@ -55,7 +71,9 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB const handleAnalyze = async (skipCache = false) => { setOpen(true); - if (markdown && !skipCache) return; // Already analyzed — reuse result + const identifier = dbId ?? schemaData.name; + // Only reuse cached markdown if it's for THIS database + if (markdown && !skipCache && cachedForRef.current === identifier) return; setLoading(true); setError(null); @@ -63,11 +81,12 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB const input = buildInput(); const result = await aiService.analyzeSchema(settings, input, { skipCache, - datasourceName: schemaData.name, + datasourceName: identifier, }); setMarkdown(result.markdown); setCached(result.cached); setCreatedAt(result.createdAt); + cachedForRef.current = identifier; // mark which DB this result belongs to } catch (err: any) { setError(err?.message ?? String(err)); } finally { @@ -79,6 +98,7 @@ export function AnalyzeSchemaButton({ schemaData, databaseType }: AnalyzeSchemaB setMarkdown(undefined); setCached(undefined); setCreatedAt(undefined); + cachedForRef.current = undefined; handleAnalyze(true); }; diff --git a/src/features/schema-explorer/components/SchemaExplorerHeader.tsx b/src/features/schema-explorer/components/SchemaExplorerHeader.tsx index 84f884d..2d83e2b 100644 --- a/src/features/schema-explorer/components/SchemaExplorerHeader.tsx +++ b/src/features/schema-explorer/components/SchemaExplorerHeader.tsx @@ -51,6 +51,7 @@ const SchemaExplorerHeader = ({ dbId, database, onTableCreated, selectedTable }: {database.schemas && database.schemas.length > 0 && ( )} diff --git a/src/features/schema-explorer/components/SchemaExplorerPanel.tsx b/src/features/schema-explorer/components/SchemaExplorerPanel.tsx index 360bd82..9b03406 100644 --- a/src/features/schema-explorer/components/SchemaExplorerPanel.tsx +++ b/src/features/schema-explorer/components/SchemaExplorerPanel.tsx @@ -6,7 +6,7 @@ import SchemaExplorerHeader from "./SchemaExplorerHeader"; import MetaDataPanel from "./MetaDataPanel"; import { useSchemaExplorerPanel } from "../hooks/useSchemaExplorerPanel"; import { TreeViewPanel } from "@/features/tree"; - +import { SchemaExplorerPanelLoadingState } from "./SchemaExplorerPanelLoadingState"; interface Column extends ColumnDetails { foreignKeyRef?: string; @@ -53,12 +53,11 @@ export default function SchemaExplorerPanel({ dbId, projectId }: SchemaExplorerP // --- Conditional rendering --- if (isLoading) { return ( -
- -
+ ); } + if (error || !schemaData) { return (
diff --git a/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx b/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx new file mode 100644 index 0000000..ddc5f51 --- /dev/null +++ b/src/features/schema-explorer/components/SchemaExplorerPanelLoadingState.tsx @@ -0,0 +1,103 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function SchemaExplorerPanelLoadingState() { + return ( +
+ {/* ── Header (breadcrumb + actions) ── */} +
+
+ + + +
+
+ + +
+
+ + {/* ── Body: left tree + right metadata ── */} +
+ + {/* Left tree panel */} +
+ + {/* Database root node */} +
+ + + +
+ + {/* Schema node (expanded) */} +
+ + + + +
+ + {/* Table rows under schema */} + {[ + "w-20", "w-16", "w-14", "w-12", + "w-16", "w-20", "w-24", "w-28", + "w-22", "w-26", "w-32", "w-18", "w-16", + ].map((w, i) => ( +
+ + + + {/* Occasional FK badge */} + {i === 7 && ( + + )} +
+ ))} +
+ + {/* Right metadata panel — DB overview (no table selected) */} +
+ + {/* DB title row */} +
+ +
+ + +
+
+ {/* 2 × 3 stat cards grid */} +
+ {[ + { w: "w-6", label: "w-14" }, + { w: "w-8", label: "w-12" }, + { w: "w-10", label: "w-16" }, + { w: "w-4", label: "w-20" }, + { w: "w-8", label: "w-14" }, + { w: "w-4", label: "w-10" }, + ].map((card, i) => ( +
+ {/* Big number */} + + {/* Label */} + +
+ ))} +
+
+
+ + {/* ── Footer ── */} +
+
+ +
+
+ +
+
+ ) +} diff --git a/src/features/settings/components/AISettings.tsx b/src/features/settings/components/AISettings.tsx index db3b460..3e1adb0 100644 --- a/src/features/settings/components/AISettings.tsx +++ b/src/features/settings/components/AISettings.tsx @@ -1,13 +1,16 @@ import { useState, useEffect } from "react"; -import { Bot, Eye, EyeOff, CheckCircle2, XCircle, Loader2, ChevronDown } from "lucide-react"; +import { Bot, Eye, EyeOff, CheckCircle2, XCircle, Loader2, ChevronDown, Zap, Scale, Brain, ExternalLink } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + DropdownMenuSeparator, + DropdownMenuLabel, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { @@ -18,15 +21,68 @@ import { aiService, } from "@/services/bridge/ai"; +// ── Model catalog ───────────────────────────────────────────────────────── + +type ModelTier = "fast" | "balanced" | "powerful"; + +interface ModelOption { + value: string; + label: string; + tier: ModelTier; +} + +const MODEL_OPTIONS: Partial> = { + anthropic: [ + { value: "claude-3-5-haiku-20241022", label: "Haiku 3.5", tier: "fast" }, + { value: "claude-3-5-sonnet-20241022", label: "Sonnet 3.5", tier: "balanced" }, + { value: "claude-3-7-sonnet-20250219", label: "Sonnet 3.7", tier: "balanced" }, + { value: "claude-opus-4-5", label: "Opus 4.5", tier: "powerful" }, + ], + openai: [ + { value: "gpt-4o-mini", label: "GPT-4o Mini", tier: "fast" }, + { value: "gpt-4o", label: "GPT-4o", tier: "balanced" }, + { value: "o1-mini", label: "o1 Mini", tier: "balanced" }, + { value: "o1", label: "o1", tier: "powerful" }, + { value: "o3-mini", label: "o3 Mini", tier: "powerful" }, + ], + gemini: [ + { value: "gemini-1.5-flash", label: "Gemini 1.5 Flash", tier: "fast" }, + { value: "gemini-1.5-pro", label: "Gemini 1.5 Pro", tier: "balanced" }, + { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash", tier: "fast" }, + { value: "gemini-2.5-pro-preview-06-05", label: "Gemini 2.5 Pro", tier: "powerful" }, + ], + groq: [ + { value: "llama-3.1-8b-instant", label: "Llama 3.1 8B", tier: "fast" }, + { value: "llama-3.3-70b-versatile", label: "Llama 3.3 70B", tier: "balanced" }, + { value: "moonshotai/kimi-k2-instruct", label: "Kimi K2", tier: "powerful" }, + ], + mistral: [ + { value: "mistral-small-latest", label: "Mistral Small", tier: "fast" }, + { value: "mistral-medium-latest", label: "Mistral Medium", tier: "balanced" }, + { value: "mistral-large-latest", label: "Mistral Large", tier: "powerful" }, + { value: "codestral-latest", label: "Codestral", tier: "powerful" }, + ], +}; + +// Map provider name to the settings key that stores its model +const MODEL_FIELD: Partial> = { + anthropic: "anthropicModel", + openai: "openaiModel", + gemini: "geminiModel", + groq: "groqModel", + mistral: "mistralModel", +}; + // ── Provider metadata ───────────────────────────────────────────────────── interface ProviderMeta { name: AIProviderName; label: string; - description: string; + defaultModelLabel: string; requiresKey: boolean; keyField?: keyof AISettingsData; keyPlaceholder?: string; + docsUrl?: string; extraFields?: Array<{ field: keyof AISettingsData; label: string; @@ -39,47 +95,52 @@ const PROVIDERS: ProviderMeta[] = [ { name: "anthropic", label: "Claude (Anthropic)", - description: "claude-3-5-haiku-20241022", + defaultModelLabel: "Haiku 3.5", requiresKey: true, keyField: "anthropicApiKey", keyPlaceholder: "sk-ant-api03-…", + docsUrl: "https://console.anthropic.com/settings/keys", }, { name: "openai", label: "OpenAI", - description: "gpt-4o-mini", + defaultModelLabel: "GPT-4o Mini", requiresKey: true, keyField: "openaiApiKey", keyPlaceholder: "sk-proj-…", + docsUrl: "https://platform.openai.com/api-keys", }, { name: "gemini", label: "Gemini (Google)", - description: "gemini-1.5-flash", + defaultModelLabel: "Gemini 1.5 Flash", requiresKey: true, keyField: "geminiApiKey", keyPlaceholder: "AIzaSy…", + docsUrl: "https://aistudio.google.com/app/apikey", }, { name: "groq", label: "Groq", - description: "llama-3.3-70b-versatile", + defaultModelLabel: "Llama 3.3 70B", requiresKey: true, keyField: "groqApiKey", keyPlaceholder: "gsk_…", + docsUrl: "https://console.groq.com/keys", }, { name: "mistral", label: "Mistral", - description: "mistral-small-latest", + defaultModelLabel: "Mistral Small", requiresKey: true, keyField: "mistralApiKey", keyPlaceholder: "…", + docsUrl: "https://console.mistral.ai/api-keys", }, { name: "ollama", label: "Ollama (Local)", - description: "Runs entirely on your machine", + defaultModelLabel: "llama3.2", requiresKey: false, extraFields: [ { @@ -96,7 +157,112 @@ const PROVIDERS: ProviderMeta[] = [ }, ]; -// ── Connection status indicator ─────────────────────────────────────────── +// ── Tier badge ──────────────────────────────────────────────────────────── + +const TIER_CONFIG: Record = { + fast: { + label: "Fast", + icon: Zap, + className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + }, + balanced: { + label: "Balanced", + icon: Scale, + className: "bg-blue-500/10 text-blue-500 border-blue-500/20", + }, + powerful: { + label: "Powerful", + icon: Brain, + className: "bg-purple-500/10 text-purple-500 border-purple-500/20", + }, +}; + +function TierBadge({ tier }: { tier: ModelTier }) { + const { label, icon: Icon, className } = TIER_CONFIG[tier]; + return ( + + + {label} + + ); +} + +// ── Model selector ──────────────────────────────────────────────────────── + +function ModelSelector({ + provider, + value, + onChange, +}: { + provider: AIProviderName; + value: string | undefined; + onChange: (model: string) => void; +}) { + const options = MODEL_OPTIONS[provider]; + if (!options || options.length === 0) return null; + + const selected = options.find((o) => o.value === value) ?? options[0]; + + const byTier: Record = { fast: [], balanced: [], powerful: [] }; + options.forEach((o) => byTier[o.tier].push(o)); + + return ( +
+ + + + + + + {(["fast", "balanced", "powerful"] as ModelTier[]).map((tier) => { + const tierOptions = byTier[tier]; + if (tierOptions.length === 0) return null; + const { label, icon: Icon, className: badgeCls } = TIER_CONFIG[tier]; + return ( +
+ + + {label} + + {tierOptions.map((opt) => ( + onChange(opt.value)} + > + {opt.label} + {opt.value} + + ))} + +
+ ); + })} + + Selected: {selected.value} + +
+
+
+ ); +} + +// ── Connection status ───────────────────────────────────────────────────── type ConnectionStatus = "idle" | "testing" | "ok" | "error"; @@ -117,7 +283,7 @@ function StatusBadge({ status, message }: { status: ConnectionStatus; message?: ); } -// ── Password field with show/hide ───────────────────────────────────────── +// ── Password field ──────────────────────────────────────────────────────── function SecretInput({ value, @@ -159,14 +325,18 @@ function SecretInput({ // ── Main component ──────────────────────────────────────────────────────── export default function AISettings() { - const [settings, setSettings] = useState(loadAISettings); + const [settings, setSettings] = useState({ defaultProvider: "ollama" }); + const [isLoading, setIsLoading] = useState(true); const [dirty, setDirty] = useState(false); const [status, setStatus] = useState("idle"); const [statusMessage, setStatusMessage] = useState(); - // Load from storage on mount + // Load from bridge (ai-settings.json) on mount useEffect(() => { - setSettings(loadAISettings()); + loadAISettings().then((loaded) => { + setSettings(loaded); + setIsLoading(false); + }); }, []); const activeProvider = PROVIDERS.find((p) => p.name === settings.defaultProvider) ?? PROVIDERS[0]; @@ -177,15 +347,14 @@ export default function AISettings() { setStatus("idle"); }; - const handleSave = () => { - saveAISettings(settings); + const handleSave = async () => { + await saveAISettings(settings); setDirty(false); setStatus("idle"); }; const handleTest = async () => { - // Save first so the bridge gets the latest values - saveAISettings(settings); + await saveAISettings(settings); setDirty(false); setStatus("testing"); setStatusMessage(undefined); @@ -198,6 +367,34 @@ export default function AISettings() { } }; + // Get the currently selected model label for the provider description + const activeModelField = MODEL_FIELD[activeProvider.name]; + const activeModelValue = activeModelField ? (settings[activeModelField] as string | undefined) : undefined; + const activeModelOptions = MODEL_OPTIONS[activeProvider.name] ?? []; + const activeModelInfo = activeModelOptions.find((o) => o.value === activeModelValue) ?? activeModelOptions[0]; + + // Which providers already have keys saved (excluding ollama which needs no key) + const configuredProviders = PROVIDERS.filter((p) => { + if (!p.requiresKey || !p.keyField) return false; + return !!((settings[p.keyField] as string | undefined)?.trim()); + }); + const configuredCount = configuredProviders.length; + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + return (
{/* Section header */} @@ -208,7 +405,7 @@ export default function AISettings() {

AI Settings

- Configure your AI provider. Keys stay on your machine. + Configure your AI provider and model. Keys stay on your machine.

@@ -218,7 +415,22 @@ export default function AISettings() {
-

{activeProvider.description}

+

+ {activeModelInfo ? `${activeModelInfo.label} · ` : ""}{activeProvider.label} +

+ {configuredCount > 0 && ( +

+ + {configuredProviders.map((p) => ( + + + {p.label.split(" ")[0]} + + ))} + + saved +

+ )}
@@ -227,31 +439,70 @@ export default function AISettings() { - - {PROVIDERS.map((p) => ( - update({ defaultProvider: p.name })} - > - {p.label} - {p.description} - - ))} + + {PROVIDERS.map((p) => { + const hasKey = p.requiresKey && p.keyField + ? !!((settings[p.keyField] as string | undefined)?.trim()) + : !p.requiresKey; // ollama always "configured" + return ( + update({ defaultProvider: p.name })} + > + {/* Green dot = key saved, grey = not configured */} + +
+ {p.label} + {p.defaultModelLabel} +
+ {p.name === settings.defaultProvider && ( + Active + )} +
+ ); + })} + {configuredCount > 0 && ( + <> + + + {configuredCount} provider{configuredCount !== 1 ? "s" : ""} with saved keys — switching won't delete them. + + + )}
- {/* Credential fields for the active provider */} + {/* Credential + model fields for the active provider */}
+ {/* API Key */} {activeProvider.requiresKey && activeProvider.keyField && (
- +
+ + {activeProvider.docsUrl && ( + + Get key + + )} +
)} + {/* Model selector (for providers with a model catalog) */} + {activeProvider.name !== "ollama" && MODEL_FIELD[activeProvider.name] && ( + update({ [MODEL_FIELD[activeProvider.name]!]: model })} + /> + )} + + {/* Extra fields (Ollama base URL / model text input) */} {activeProvider.extraFields?.map((field) => (
- {/* All providers key summary (collapsed) */} + {/* Configure other providers (collapsed) */}
Configure other providers
- {PROVIDERS.filter((p) => p.name !== settings.defaultProvider && p.requiresKey).map((provider) => ( -
- - update({ [provider.keyField!]: v })} - placeholder={provider.keyPlaceholder} - /> -
- ))} + {PROVIDERS.filter((p) => p.name !== settings.defaultProvider && p.requiresKey).map((provider) => { + const modelField = MODEL_FIELD[provider.name]; + const modelOptions = MODEL_OPTIONS[provider.name] ?? []; + const selectedModel = modelField ? (settings[modelField] as string | undefined) : undefined; + const selectedModelInfo = modelOptions.find((o) => o.value === selectedModel) ?? modelOptions[0]; + return ( +
+
+ + {provider.docsUrl && ( + + Get key + + )} +
+ update({ [provider.keyField!]: v })} + placeholder={provider.keyPlaceholder} + /> + {modelField && modelOptions.length > 0 && ( + update({ [modelField]: model })} + /> + )} +
+ ); + })}
diff --git a/src/features/workspace/components/ExplainQueryButton.tsx b/src/features/workspace/components/ExplainQueryButton.tsx index 717c03d..dab13a2 100644 --- a/src/features/workspace/components/ExplainQueryButton.tsx +++ b/src/features/workspace/components/ExplainQueryButton.tsx @@ -12,7 +12,7 @@ interface ExplainQueryButtonProps { } export function ExplainQueryButton({ sql, disabled, databaseName }: ExplainQueryButtonProps) { - const settings = useAISettings(); + const { settings } = useAISettings(); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [markdown, setMarkdown] = useState(); diff --git a/src/features/workspace/components/SQLWorkspacePanel.tsx b/src/features/workspace/components/SQLWorkspacePanel.tsx index 5e9a3f8..7444461 100644 --- a/src/features/workspace/components/SQLWorkspacePanel.tsx +++ b/src/features/workspace/components/SQLWorkspacePanel.tsx @@ -9,6 +9,7 @@ import { ResultsPanel } from "./ResultsPanel"; import { StatusBar } from "./StatusBar"; import { QueryTab, QueryHistoryItem } from "../types"; import { SqlEditor } from "./SqlEditor"; +import { SQLWorkspacePanelLoadingState } from "@/features/workspace/components/SQLWorkspacePanelLoadingState"; interface SQLWorkspacePanelProps { dbId: string; @@ -154,9 +155,7 @@ const SQLWorkspacePanel = ({ dbId }: SQLWorkspacePanelProps) => { if (!bridgeReady) { return ( -
- -
+ ); } diff --git a/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx b/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx new file mode 100644 index 0000000..adfbe5d --- /dev/null +++ b/src/features/workspace/components/SQLWorkspacePanelLoadingState.tsx @@ -0,0 +1,60 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { Card } from "@/components/ui/card"; + +export function SQLWorkspacePanelLoadingState() { + return ( +
+ {/* Header skeleton */} +
+
+ + +
+
+ + +
+
+ {/* Body */} +
+ {/* Sidebar skeleton */} + + + {Array.from({ length: 7 }).map((_, i) => ( +
+ + +
+ ))} +
+ {/* Editor area skeleton */} + + {/* Tab bar */} +
+ + + +
+ {/* Code area */} +
+ {["w-1/3", "w-2/5", "w-1/2", "w-1/4", "w-2/3", "w-1/3"].map((w, i) => ( + + ))} +
+ {/* Results area */} +
+ + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+
+ {/* Status bar */} +
+ + +
+
+ ) +} diff --git a/src/lib/schemaTransformer.ts b/src/lib/schemaTransformer.ts index cda8344..c8fec53 100644 --- a/src/lib/schemaTransformer.ts +++ b/src/lib/schemaTransformer.ts @@ -212,7 +212,7 @@ export const transformSchemaToER = ( }, labelBgPadding: [4, 4] as [number, number], labelBgBorderRadius: 4, - type: "smoothstep", + type: "default", data: { constraintName: fk.constraint_name, updateRule: fk.update_rule, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 19215ca..14b7fdd 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -41,3 +41,22 @@ export function formatRelativeTime(dateString?: string | null): string { if (diffDays < 7) return `${diffDays}d ago`; return formatTimestamp(dateString); } + +export function formatDbType(databaseType?: string) { + if (!databaseType) return "Database"; + if (databaseType === "postgres" || databaseType === "postgresql") return "PostgreSQL"; + if (databaseType === "mysql") return "MySQL"; + if (databaseType === "mariadb") return "MariaDB"; + return databaseType; +} +export function formatDuration(seconds: number) { + if (seconds < 60) return `${Math.round(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remaining = Math.round(seconds % 60); + return `${minutes}m ${remaining}s`; +} + +export function formatNumber(value: number) { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value); +} + diff --git a/src/services/bridge/ai.ts b/src/services/bridge/ai.ts index e3e3784..6d5dbb1 100644 --- a/src/services/bridge/ai.ts +++ b/src/services/bridge/ai.ts @@ -20,6 +20,12 @@ export interface AISettings { mistralApiKey?: string; ollamaBaseUrl?: string; ollamaModel?: string; + // Per-provider selected model + anthropicModel?: string; + openaiModel?: string; + geminiModel?: string; + groqModel?: string; + mistralModel?: string; } export interface SchemaAnalysisInput { @@ -109,20 +115,70 @@ export interface AIHistoryListResult { total: number; } -// ── AI Settings storage ─────────────────────────────────────────────────── +// ── AI Settings storage (persisted to ~/.relwave/ai-settings.json via bridge) ── -const AI_SETTINGS_KEY = "relwave:ai-settings"; +const LS_MIGRATION_KEY = "relwave:ai-settings-migrated-v2"; +const LS_LEGACY_KEY = "relwave:ai-settings"; -export function loadAISettings(): AISettings { +const DEFAULT_SETTINGS: AISettings = { defaultProvider: "ollama" }; + +/** + * Load AI settings from the bridge (reads ai-settings.json on disk). + * Falls back to empty defaults if the file doesn't exist yet. + * Also performs a one-time migration of any settings previously saved in localStorage. + */ +export async function loadAISettings(): Promise { try { - const raw = localStorage.getItem(AI_SETTINGS_KEY); - if (raw) return JSON.parse(raw) as AISettings; - } catch { /* ignore */ } - return { defaultProvider: "ollama" }; + const result = await bridgeRequest("ai.loadSettings", {}); + const fromFile = (result?.data ?? {}) as Partial; + + // If nothing is on disk yet, check localStorage for a legacy migration + if (!fromFile.defaultProvider) { + const migrated = migrateFromLocalStorage(); + if (migrated) { + // Persist the migrated settings to disk right away + await saveAISettings(migrated); + return migrated; + } + return { ...DEFAULT_SETTINGS }; + } + + return { ...DEFAULT_SETTINGS, ...fromFile }; + } catch { + // Bridge unavailable (e.g. during Vite standalone dev) — degrade gracefully + return migrateFromLocalStorage() ?? { ...DEFAULT_SETTINGS }; + } } -export function saveAISettings(settings: AISettings): void { - localStorage.setItem(AI_SETTINGS_KEY, JSON.stringify(settings)); +/** + * Save AI settings to disk via the bridge (writes ai-settings.json). + */ +export async function saveAISettings(settings: AISettings): Promise { + try { + await bridgeRequest("ai.saveSettings", { settings }); + } catch { + // Fallback: keep a copy in localStorage so settings aren’t totally lost + localStorage.setItem(LS_LEGACY_KEY, JSON.stringify(settings)); + } +} + +/** + * One-time migration: if the user had settings saved in the old localStorage + * key, return them and mark migration as done so we don’t do it again. + */ +function migrateFromLocalStorage(): AISettings | null { + if (localStorage.getItem(LS_MIGRATION_KEY)) return null; // already done + try { + const raw = localStorage.getItem(LS_LEGACY_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as AISettings; + // Mark done so this code only runs once + localStorage.setItem(LS_MIGRATION_KEY, "1"); + localStorage.removeItem(LS_LEGACY_KEY); + return parsed; + } catch { + return null; + } } // ── Bridge service class ──────────────────────────────────────────────────