feat: add project
This commit is contained in:
@@ -13,6 +13,7 @@ import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { InlineVisualizationCard } from "./InlineVisualizationCard";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -76,6 +77,7 @@ export function ChatInterface() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
const { currentProject } = useProjectStore();
|
||||
|
||||
// Model selection state
|
||||
const [models, setModels] = useState<ModelConfig[]>([]);
|
||||
@@ -83,10 +85,7 @@ export function ChatInterface() {
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
|
||||
// Data Source selection state
|
||||
const [availableDataSources, setAvailableDataSources] = useState<{id: string, name: string}[]>([
|
||||
{ id: "postgres-main", name: "PostgreSQL" },
|
||||
{ id: "clickhouse-main", name: "ClickHouse" }
|
||||
]);
|
||||
const [availableDataSources, setAvailableDataSources] = useState<{id: string, name: string}[]>([]);
|
||||
|
||||
// Try to parse active session from URL query
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
@@ -101,16 +100,29 @@ export function ChatInterface() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels();
|
||||
fetchDataSources();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
fetchDataSources();
|
||||
}
|
||||
}, [currentProject]);
|
||||
|
||||
const fetchDataSources = async () => {
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
const data = await api.get<Array<{id: number, name: string}>>("/api/v1/datasources");
|
||||
setAvailableDataSources(prev => [
|
||||
...prev.filter(d => !d.id.startsWith("ds:")),
|
||||
...data.map(d => ({ id: `ds:${d.id}`, name: d.name }))
|
||||
]);
|
||||
const data = await api.get<Array<{id: number, name: string}>>(`/api/v1/datasources?project_id=${currentProject.id}`);
|
||||
const projectSources = data.map(d => ({ id: `ds:${d.id}`, name: d.name }));
|
||||
setAvailableDataSources(projectSources);
|
||||
|
||||
// Default select the first one if current selection is not in the list
|
||||
if (projectSources.length > 0) {
|
||||
if (!selectedDataSource.startsWith("ds:") || !projectSources.find(ds => ds.id === selectedDataSource)) {
|
||||
setSelectedDataSource(projectSources[0].id);
|
||||
}
|
||||
} else {
|
||||
setSelectedDataSource("upload"); // Default to upload if no data sources
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch data sources", e);
|
||||
}
|
||||
@@ -282,14 +294,18 @@ export function ChatInterface() {
|
||||
useEffect(() => {
|
||||
const fetchSkills = async () => {
|
||||
try {
|
||||
const skills = await api.get<Skill[]>("/api/v1/skills");
|
||||
let url = "/api/v1/skills";
|
||||
if (currentProject) {
|
||||
url += `?project_id=${currentProject.id}`;
|
||||
}
|
||||
const skills = await api.get<Skill[]>(url);
|
||||
setAvailableSkills(skills);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch skills:", err);
|
||||
}
|
||||
};
|
||||
fetchSkills();
|
||||
}, []);
|
||||
}, [currentProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
@@ -340,12 +356,21 @@ export function ChatInterface() {
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const effectiveModelId = selectedModelId || currentModel?.id || "";
|
||||
const selectedSource = selectedDataSource.split('-')[0];
|
||||
|
||||
// Correctly parse source from selectedDataSource (could be 'ds:ID', 'upload', or legacy 'postgres-main')
|
||||
let source = selectedDataSource;
|
||||
if (selectedDataSource.includes("-")) {
|
||||
source = selectedDataSource.split("-")[0];
|
||||
}
|
||||
|
||||
const useUploadSource = Boolean(
|
||||
currentAttachedFile?.url?.startsWith("local://") ||
|
||||
(selectedSource === "upload" && activeDataFile?.url?.startsWith("local://"))
|
||||
(source === "upload" && activeDataFile?.url?.startsWith("local://"))
|
||||
);
|
||||
const source = useUploadSource ? "upload" : selectedSource;
|
||||
if (useUploadSource) {
|
||||
source = "upload";
|
||||
}
|
||||
|
||||
const fileUrl = useUploadSource ? (currentAttachedFile?.url || activeDataFile?.url) : undefined;
|
||||
const preferSqlChart = chartIntentPattern.test(messagePayload);
|
||||
const response = await fetch("/nanobot/chat/stream", {
|
||||
@@ -570,19 +595,11 @@ export function ChatInterface() {
|
||||
数据源
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{[
|
||||
{ id: 'postgres-main', label: 'Postgres (Main)', icon: Database },
|
||||
{ id: 'clickhouse-main', label: 'Clickhouse', icon: Database },
|
||||
{ id: 'upload', label: '本地文件上传', icon: FileIcon },
|
||||
].map((ds) => (
|
||||
{availableDataSources.map((ds) => (
|
||||
<button
|
||||
key={ds.id}
|
||||
onClick={() => {
|
||||
setSelectedDataSource(ds.id);
|
||||
if (ds.id === 'upload') {
|
||||
fileInputRef.current?.click();
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
|
||||
@@ -592,12 +609,32 @@ export function ChatInterface() {
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ds.icon className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-zinc-400")} />
|
||||
<span className="font-medium">{ds.label}</span>
|
||||
<Database className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-zinc-400")} />
|
||||
<span className="font-medium">{ds.name}</span>
|
||||
</div>
|
||||
{selectedDataSource === ds.id && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDataSource('upload');
|
||||
fileInputRef.current?.click();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
|
||||
selectedDataSource === 'upload' || selectedDataSource === 'upload-main'
|
||||
? "bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-200"
|
||||
: "text-zinc-600 hover:bg-white hover:shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileIcon className={cn("h-4 w-4", (selectedDataSource === 'upload' || selectedDataSource === 'upload-main') ? "text-blue-500" : "text-zinc-400")} />
|
||||
<span className="font-medium">本地文件上传</span>
|
||||
</div>
|
||||
{(selectedDataSource === 'upload' || selectedDataSource === 'upload-main') && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -776,19 +813,11 @@ export function ChatInterface() {
|
||||
数据源
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{[
|
||||
{ id: 'postgres-main', label: 'Postgres (Main)', icon: Database },
|
||||
{ id: 'clickhouse-main', label: 'Clickhouse', icon: Database },
|
||||
{ id: 'upload', label: '本地文件上传', icon: FileIcon },
|
||||
].map((ds) => (
|
||||
{availableDataSources.map((ds) => (
|
||||
<button
|
||||
key={ds.id}
|
||||
onClick={() => {
|
||||
setSelectedDataSource(ds.id);
|
||||
if (ds.id === 'upload') {
|
||||
fileInputRef.current?.click();
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
|
||||
@@ -798,12 +827,32 @@ export function ChatInterface() {
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ds.icon className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-zinc-400")} />
|
||||
<span className="font-medium">{ds.label}</span>
|
||||
<Database className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-zinc-400")} />
|
||||
<span className="font-medium">{ds.name}</span>
|
||||
</div>
|
||||
{selectedDataSource === ds.id && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDataSource('upload');
|
||||
fileInputRef.current?.click();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
|
||||
selectedDataSource === 'upload' || selectedDataSource === 'upload-main'
|
||||
? "bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-200"
|
||||
: "text-zinc-600 hover:bg-white hover:shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileIcon className={cn("h-4 w-4", (selectedDataSource === 'upload' || selectedDataSource === 'upload-main') ? "text-blue-500" : "text-zinc-400")} />
|
||||
<span className="font-medium">本地文件上传</span>
|
||||
</div>
|
||||
{(selectedDataSource === 'upload' || selectedDataSource === 'upload-main') && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Code, Table as TableIcon, BarChart as ChartIcon, LayoutDashboard } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import type { ChartSpec } from "@/store/visualizationStore";
|
||||
import { VegaChart } from "./VegaChart";
|
||||
|
||||
@@ -25,6 +26,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
|
||||
const { addChart } = useDashboardStore();
|
||||
const { currentProject } = useProjectStore();
|
||||
const objectRows = viz.rows.filter((row) => row && typeof row === "object" && !Array.isArray(row)) as Record<string, unknown>[];
|
||||
const columns = objectRows.length > 0 ? Object.keys(objectRows[0]) : [];
|
||||
|
||||
@@ -43,14 +45,15 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
|
||||
};
|
||||
|
||||
const handleAddToDashboard = () => {
|
||||
if (!currentProject) return;
|
||||
const chart = buildPendingChart();
|
||||
setPendingChart(chart);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmAdd = () => {
|
||||
if (!pendingChart) return;
|
||||
addChart(pendingChart);
|
||||
if (!pendingChart || !currentProject) return;
|
||||
addChart(pendingChart, currentProject.id);
|
||||
setConfirmOpen(false);
|
||||
setPendingChart(null);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChevronDown, Plus, Folder } from 'lucide-react';
|
||||
import { useProjectStore, type Project } from '@/store/projectStore';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuGroup,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export function ProjectSwitcher() {
|
||||
const { projects, currentProject, fetchProjects, setCurrentProject, addProject } = useProjectStore();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, [fetchProjects]);
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!newProjectName.trim()) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await addProject(newProjectName);
|
||||
setNewProjectName('');
|
||||
setIsCreateDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-background h-12">
|
||||
<div className="flex items-center text-sm font-medium text-muted-foreground">
|
||||
<span>DataClaw</span>
|
||||
<span className="mx-2 text-zinc-300">/</span>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex h-8 items-center gap-1 rounded-md px-2 font-semibold hover:bg-accent hover:text-accent-foreground outline-none transition-colors">
|
||||
<Folder className="h-4 w-4 mr-1 text-blue-500" />
|
||||
{currentProject?.name || 'Select Project'}
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
PROJECTS
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsCreateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuGroup>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{projects.map((project) => (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
setCurrentProject(project);
|
||||
}}
|
||||
className={currentProject?.id === project.id ? 'bg-accent' : ''}
|
||||
>
|
||||
<Folder className="h-4 w-4 mr-2 text-zinc-400" />
|
||||
{project.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{projects.length === 0 && (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
No projects found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Project</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Project Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Enter project name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreateProject} disabled={isSubmitting || !newProjectName.trim()}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Project'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2 } from "lucide-react";
|
||||
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2, Folder } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Link, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
@@ -561,6 +561,28 @@ function SidebarBody() {
|
||||
<p className="text-xs text-zinc-500 truncate">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
|
||||
onClick={() => {
|
||||
navigate("/projects");
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
>
|
||||
<Folder className="h-4 w-4 text-zinc-500" />
|
||||
项目管理
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
|
||||
onClick={() => {
|
||||
navigate("/datasources");
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
>
|
||||
<Database className="h-4 w-4 text-zinc-500" />
|
||||
数据源管理
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
|
||||
onClick={() => {
|
||||
@@ -584,17 +606,6 @@ function SidebarBody() {
|
||||
<Brain className="h-4 w-4 text-zinc-500" />
|
||||
模型配置
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
|
||||
onClick={() => {
|
||||
navigate("/datasources");
|
||||
setShowUserMenu(false);
|
||||
}}
|
||||
>
|
||||
<Database className="h-4 w-4 text-zinc-500" />
|
||||
数据源配置
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50 transition-colors"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Code, Table as TableIcon, BarChart as ChartIcon, Download, LayoutDashbo
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
|
||||
import { useVisualizationStore } from "@/store/visualizationStore";
|
||||
import { useProjectStore } from "@/store/projectStore";
|
||||
import { VegaChart } from "./VegaChart";
|
||||
|
||||
export function VisualizationPanel() {
|
||||
@@ -14,6 +15,7 @@ export function VisualizationPanel() {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
|
||||
const { addChart } = useDashboardStore();
|
||||
const { currentProject } = useProjectStore();
|
||||
const { currentData, currentSQL, currentChartSpec, currentChartInfo, isLoading, error } = useVisualizationStore();
|
||||
|
||||
const buildPendingChart = (): Omit<ChartConfig, 'layout'> | null => {
|
||||
@@ -32,6 +34,7 @@ export function VisualizationPanel() {
|
||||
};
|
||||
|
||||
const handleAddToDashboard = () => {
|
||||
if (!currentProject) return;
|
||||
const chart = buildPendingChart();
|
||||
if (!chart) return;
|
||||
setPendingChart(chart);
|
||||
@@ -39,8 +42,8 @@ export function VisualizationPanel() {
|
||||
};
|
||||
|
||||
const handleConfirmAdd = () => {
|
||||
if (!pendingChart) return;
|
||||
addChart(pendingChart);
|
||||
if (!pendingChart || !currentProject) return;
|
||||
addChart(pendingChart, currentProject.id);
|
||||
setConfirmOpen(false);
|
||||
setPendingChart(null);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user