feat: add n18n

This commit is contained in:
qixinbo
2026-03-21 21:26:57 +08:00
parent 40f84fc98e
commit 5ab9884bf6
22 changed files with 823 additions and 273 deletions
+38 -38
View File
@@ -1,8 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { User, Loader2, Sparkles, ArrowUp, ChevronDown, Paperclip, Check, X, Square, Plus, Database, Wand2, Search, Zap, LayoutGrid, CheckCircle2, Table, XCircle, Settings, ExternalLink } from "lucide-react";
import { User, Loader2, ArrowUp, ChevronDown, Check, Square, Plus, Database, Wand2, Zap, CheckCircle2, Table, XCircle, Settings, ExternalLink } from "lucide-react";
import { api } from "@/lib/api";
import { type ChartSpec } from "@/store/visualizationStore";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -12,6 +10,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { InlineVisualizationCard } from "./InlineVisualizationCard";
import { useProjectStore } from "@/store/projectStore";
import { SlashCommandMenu } from "./SlashCommandMenu";
@@ -99,6 +98,7 @@ interface SessionData {
}
export function ChatInterface() {
const { t } = useTranslation();
const [messagesBySession, setMessagesBySession] = useState<Record<string, Message[]>>({});
const [input, setInput] = useState("");
const [selectedDataSource, setSelectedDataSource] = useState<string>("");
@@ -214,7 +214,7 @@ export function ChatInterface() {
// File upload state
const [attachedFile, setAttachedFile] = useState<DataFileContext | null>(null);
const [activeDataFile, setActiveDataFile] = useState<DataFileContext | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -337,7 +337,7 @@ export function ChatInterface() {
const currentModel = models.find(m => m.id === selectedModelId);
const chartIntentPattern = /(图表|可视化|画图|作图|柱状图|折线图|饼图|趋势|分布|chart|plot|visuali[sz]e)/i;
const chartIntentPattern = new RegExp(t('chartIntentPattern'), 'i');
const buildMessageViz = (payload: {
sql?: string;
@@ -419,7 +419,7 @@ export function ChatInterface() {
{selectedDataSource ? (
<div className="px-3 py-1.5 rounded-full text-xs border flex items-center gap-1.5 bg-blue-50 text-blue-700 border-blue-200">
<Database className="h-3.5 w-3.5" />
{`数据源${selectedDataSourceName}`}
{`${t('dataSource')}${selectedDataSourceName}`}
</div>
) : null}
{selectedSkills.map((skill) => (
@@ -447,7 +447,7 @@ export function ChatInterface() {
</div>
<div className="flex-1 min-w-0 pr-6">
<div className="text-sm font-bold text-zinc-900 truncate">{file.filename}</div>
<div className="text-xs text-zinc-500"></div>
<div className="text-xs text-zinc-500">{t('spreadsheet')}</div>
</div>
<button
onClick={handleRemoveFile}
@@ -491,7 +491,7 @@ export function ChatInterface() {
setMessagesForSession(activeSessionKey, (prev) =>
prev.map((msg) =>
msg.awaitingFirstToken
? { ...msg, awaitingFirstToken: false, content: msg.content || "已中断输出" }
? { ...msg, awaitingFirstToken: false, content: msg.content || t('outputInterrupted') }
: msg
)
);
@@ -508,7 +508,7 @@ export function ChatInterface() {
let messagePayload = newMessage.content;
const currentAttachedFile = attachedFile;
if (currentAttachedFile) {
messagePayload = `[用户上传了文件: ${currentAttachedFile.filename}]\n[文件内容摘要: ${currentAttachedFile.summary || "无"}]\n[数据列: ${currentAttachedFile.columns?.join(", ") || "无"}]\n[文件下载链接: ${currentAttachedFile.url}]\n\n${newMessage.content}`;
messagePayload = `[${t('userUploadedFile')}: ${currentAttachedFile.filename}]\n[${t('fileContentSummary')}: ${currentAttachedFile.summary || t('none')}]\n[${t('dataColumns')}: ${currentAttachedFile.columns?.join(", ") || t('none')}]\n[${t('fileDownloadLink')}: ${currentAttachedFile.url}]\n\n${newMessage.content}`;
setAttachedFile(null);
}
@@ -524,7 +524,7 @@ export function ChatInterface() {
role: "assistant",
content: "",
awaitingFirstToken: true,
progressLogs: ["请求已提交,准备路由..."],
progressLogs: [t('requestSubmittedRouting')],
}]);
const pushProgressLog = (text: string, isReasoningToken: boolean = false) => {
@@ -581,7 +581,7 @@ export function ChatInterface() {
if (!response.ok || !response.body) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || "流式响应失败");
throw new Error(err.detail || t('streamResponseFailed'));
}
const reader = response.body.getReader();
@@ -651,9 +651,9 @@ export function ChatInterface() {
}
if (payload.type === "routing") {
const selected = payload.selected === "sql" ? "SQL 分析" : "通用对话";
const selected = payload.selected === "sql" ? t('sqlAnalysis') : t('generalConversation');
const reason = payload.reason ? `${payload.reason}` : "";
pushProgressLog(`路由:${selected}${reason}`);
pushProgressLog(t('routingInfo', { selected, reason }));
setMessagesForSession(targetSessionKey, (prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, routeInfo: `${selected}${reason}` } : msg
@@ -671,7 +671,7 @@ export function ChatInterface() {
hasFinalPayload = true;
streamedText = payload.content;
flushAssistant(true);
pushProgressLog("回答生成完成");
pushProgressLog(t('answerGenerationCompleted'));
setMessagesForSession(targetSessionKey, (prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: payload.content || "", awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
@@ -684,14 +684,14 @@ export function ChatInterface() {
}
if (payload.type === "error") {
throw new Error(payload.content || "流式响应错误");
throw new Error(payload.content || t('streamResponseError'));
}
if (payload.type === "viz") {
if (payload.chart?.chart_spec) {
pushProgressLog("图表生成完成");
pushProgressLog(t('chartGenerationCompleted'));
} else if (payload.sql) {
pushProgressLog("数据查询完成");
pushProgressLog(t('dataQueryCompleted'));
}
streamedViz = buildMessageViz(payload);
flushAssistant(true); // 立即把 viz 状态刷入 messages
@@ -703,7 +703,7 @@ export function ChatInterface() {
if (!streamedText && (hasFinalPayload || hasDonePayload)) {
setMessagesForSession(targetSessionKey, (prev) =>
prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: "暂无回复", awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
msg.id === assistantId ? { ...msg, content: t('noReply'), awaitingFirstToken: false, viz: streamedViz ?? msg.viz } : msg
)
);
}
@@ -712,7 +712,7 @@ export function ChatInterface() {
setMessagesForSession(targetSessionKey, (prev) =>
prev.map((msg) =>
msg.awaitingFirstToken
? { ...msg, awaitingFirstToken: false, content: msg.content || "已中断输出" }
? { ...msg, awaitingFirstToken: false, content: msg.content || t('outputInterrupted') }
: msg
)
);
@@ -746,10 +746,10 @@ export function ChatInterface() {
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="搜索模型..." />
<CommandInput placeholder={t('searchModel')} />
<CommandList className="max-h-[300px]">
<CommandEmpty></CommandEmpty>
<CommandGroup heading="可用模型">
<CommandEmpty>{t('modelNotFound')}</CommandEmpty>
<CommandGroup heading={t('availableModels')}>
{models.map((model) => (
<CommandItem
key={model.id}
@@ -818,7 +818,7 @@ export function ChatInterface() {
<div className="flex-1 p-3 bg-zinc-50/50">
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
<Database className="h-3 w-3" />
{t('dataSource')}
</div>
<div className="space-y-0.5">
{availableDataSources.map((ds) => (
@@ -849,7 +849,7 @@ export function ChatInterface() {
}}
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
>
{t('clearSelected')}
</button>
</div>
)}
@@ -893,7 +893,7 @@ export function ChatInterface() {
) : (
<div className="px-3 py-8 text-center">
<Zap className="h-8 w-8 text-zinc-100 mx-auto mb-2" />
<p className="text-xs text-zinc-400"></p>
<p className="text-xs text-zinc-400">{t('noAvailableSkills')}</p>
</div>
)}
</div>
@@ -903,7 +903,7 @@ export function ChatInterface() {
onClick={() => setSelectedSkillIds([])}
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
>
({selectedSkillIds.length})
{t('clearSelectedWithCount', { count: selectedSkillIds.length })}
</button>
</div>
)}
@@ -918,7 +918,7 @@ export function ChatInterface() {
value={input}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder="有问题,尽管问"
placeholder={t('askAnything')}
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
disabled={isLoading}
/>
@@ -984,7 +984,7 @@ export function ChatInterface() {
<div className="mb-3 rounded-xl border border-zinc-200 bg-zinc-50/50 p-3 text-sm text-zinc-600 font-mono whitespace-pre-wrap leading-relaxed shadow-inner max-h-[300px] overflow-y-auto">
<div className="flex items-center gap-2 mb-2 text-xs font-semibold text-zinc-500 uppercase tracking-wider">
<Settings className={`h-3.5 w-3.5 ${msg.awaitingFirstToken ? 'animate-spin' : ''}`} />
{t('thinkingProcess')}
</div>
{msg.reasoningContent}
</div>
@@ -993,13 +993,13 @@ export function ChatInterface() {
<div className="mb-2 rounded-xl border border-zinc-100 bg-zinc-50/70 px-3 py-2">
<div className="flex items-center gap-2 text-zinc-500 text-xs mb-1.5 pb-1.5 border-b border-zinc-100/50">
{msg.awaitingFirstToken ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />}
<span>{msg.awaitingFirstToken ? "正在处理中" : "处理完成"}</span>
<span>{msg.awaitingFirstToken ? t('processing') : t('processCompleted')}</span>
</div>
<div className="space-y-1.5 max-h-[160px] overflow-y-auto pr-1">
{msg.progressLogs.map((log, idx, arr) => {
const isLast = idx === arr.length - 1;
// 如果是正在处理的会话,且当前日志是最后一条,或者是明确包含“正在”的日志,则显示 loading
const isLoadingLog = (isLast && msg.awaitingFirstToken) || log.includes("正在");
const isLoadingLog = (isLast && msg.awaitingFirstToken) || log.includes(t('processingIndicator'));
return (
<div key={`${msg.id}-log-${idx}`} className="flex items-start gap-2 text-[12px] text-zinc-500 leading-5">
{isLoadingLog && msg.awaitingFirstToken ? (
@@ -1017,7 +1017,7 @@ export function ChatInterface() {
{msg.awaitingFirstToken && !msg.content ? (
<div className="flex items-center gap-2 text-zinc-500 text-sm py-1">
<Loader2 className="h-4 w-4 animate-spin" />
<span>...</span>
<span>{t('modelThinking')}</span>
</div>
) : (
<>
@@ -1047,7 +1047,7 @@ export function ChatInterface() {
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 rounded-lg text-sm font-medium transition-colors"
>
<ExternalLink className="h-4 w-4" />
{t('openReportInNewTab')}
</a>
</div>
) : null}
@@ -1095,7 +1095,7 @@ export function ChatInterface() {
<div className="flex-1 p-3 bg-zinc-50/50">
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider mb-2 px-2 flex items-center gap-1.5">
<Database className="h-3 w-3" />
{t('dataSource')}
</div>
<div className="space-y-0.5">
{availableDataSources.map((ds) => (
@@ -1126,7 +1126,7 @@ export function ChatInterface() {
}}
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
>
{t('clearSelected')}
</button>
</div>
)}
@@ -1170,7 +1170,7 @@ export function ChatInterface() {
) : (
<div className="px-3 py-8 text-center">
<Zap className="h-8 w-8 text-zinc-100 mx-auto mb-2" />
<p className="text-xs text-zinc-400"></p>
<p className="text-xs text-zinc-400">{t('noAvailableSkills')}</p>
</div>
)}
</div>
@@ -1180,7 +1180,7 @@ export function ChatInterface() {
onClick={() => setSelectedSkillIds([])}
className="w-full py-1.5 text-[11px] text-zinc-400 hover:text-zinc-600 transition-colors flex items-center justify-center gap-1"
>
({selectedSkillIds.length})
{t('clearSelectedWithCount', { count: selectedSkillIds.length })}
</button>
</div>
)}
@@ -1195,7 +1195,7 @@ export function ChatInterface() {
value={input}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder="有问题,尽管问"
placeholder={t('askAnything')}
className="flex-1 bg-transparent border-none focus:ring-0 text-lg px-3 py-2 text-zinc-900 placeholder:text-zinc-300 outline-none"
disabled={isLoading}
/>
@@ -1229,7 +1229,7 @@ export function ChatInterface() {
</div>
<div className="mt-2 flex justify-center">
<p className="text-[11px] text-zinc-400">
DataClaw
{t('dataClawDisclaimer')}
</p>
</div>
</div>
+17 -15
View File
@@ -2,6 +2,7 @@ import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, Check, AlertTriangle, Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import { api } from "@/lib/api";
export interface DataSourceConfig {
@@ -19,6 +20,7 @@ interface DataSourceFormProps {
}
export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: DataSourceFormProps) {
const { t } = useTranslation();
const [name, setName] = useState(initialData?.name || "");
const [type, setType] = useState(initialData?.type || "postgres");
const [config, setConfig] = useState<Record<string, any>>(initialData?.config || {});
@@ -52,7 +54,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
}
} catch (error) {
console.error("Upload failed", error);
alert("上传失败");
alert(t('uploadFailed'));
} finally {
setIsUploading(false);
// Clear input value so same file can be selected again
@@ -69,12 +71,12 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
const success = await onTest(type, config);
setTestResult({
success,
message: success ? "连接成功" : "连接失败",
message: success ? t('connectionSuccess') : t('connectionFailed'),
});
} catch (e: any) {
setTestResult({
success: false,
message: e.message || "连接失败",
message: e.message || t('connectionFailed'),
});
} finally {
setIsTesting(false);
@@ -141,7 +143,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
/>
</div>
<div className="text-xs text-zinc-500 pt-2">
使 Supabase Connection String (URI):
{t('orUseSupabaseConnectionString')}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Connection String</label>
@@ -206,7 +208,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
/>
</div>
<div className="text-xs text-zinc-500 pt-2">
使 ():
{t('orUseConnectionString')}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Connection String</label>
@@ -273,7 +275,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
return (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{t('fileUpload')}</label>
<div className="flex gap-2">
<Input
value={config.file_path || ""}
@@ -292,7 +294,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
onChange={handleFileUpload}
/>
</div>
<p className="text-xs text-zinc-500"></p>
<p className="text-xs text-zinc-500">{t('uploadFileOrEnterPath')}</p>
</div>
</div>
);
@@ -300,9 +302,9 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertTriangle className="h-10 w-10 text-amber-500 mb-3" />
<h3 className="font-medium text-zinc-900"></h3>
<h3 className="font-medium text-zinc-900">{t('unsupportedDataSourceType')}</h3>
<p className="text-sm text-zinc-500 mt-1 max-w-[300px]">
使 PostgreSQL, ClickHouse
{t('dataSourceConnectorInDevelopment')}
</p>
</div>
);
@@ -312,18 +314,18 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{t('name')}</label>
<Input
value={name}
onChange={e => setName(e.target.value)}
placeholder="我的数据源"
placeholder={t('myDataSource')}
required
/>
</div>
{!initialData?.type && (
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{t('type')}</label>
<select
className="w-full h-10 px-3 rounded-md border border-zinc-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:border-transparent"
value={type}
@@ -353,7 +355,7 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
{t('cancel')}
</Button>
<Button
type="button"
@@ -362,11 +364,11 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
disabled={isTesting}
>
{isTesting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('testConnection')}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('save')}
</Button>
</div>
</form>
+7 -3
View File
@@ -1,15 +1,16 @@
import { Component, type ReactNode } from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
type ErrorBoundaryProps = {
children: ReactNode;
};
} & WithTranslation;
type ErrorBoundaryState = {
hasError: boolean;
message: string;
};
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
class ErrorBoundaryComponent extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = {
hasError: false,
message: "",
@@ -27,11 +28,12 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
}
render() {
const { t } = this.props;
if (this.state.hasError) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-background text-foreground p-6">
<div className="max-w-lg text-center">
<h1 className="text-xl font-semibold mb-2"></h1>
<h1 className="text-xl font-semibold mb-2">{t('pageRenderFailed')}</h1>
<p className="text-sm text-muted-foreground break-words">{this.state.message}</p>
</div>
</div>
@@ -41,3 +43,5 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
return this.props.children;
}
}
export const ErrorBoundary = withTranslation()(ErrorBoundaryComponent);
@@ -1,12 +1,13 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Code, Table as TableIcon, BarChart as ChartIcon, LayoutDashboard, Copy, Check } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
import { useProjectStore } from "@/store/projectStore";
import { useTranslation } from "react-i18next";
import type { ChartSpec } from "@/store/visualizationStore";
import { VegaChart } from "./VegaChart";
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
@@ -25,6 +26,7 @@ interface InlineVisualizationCardProps {
}
export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
const { t } = useTranslation();
const [view, setView] = useState<'table' | 'chart'>('chart');
const [confirmOpen, setConfirmOpen] = useState(false);
const [copied, setCopied] = useState(false);
@@ -87,7 +89,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
return (
<Card className="w-full border border-zinc-100 shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-base">{viz.chartSpec?.title || "可视化结果"}</CardTitle>
<CardTitle className="text-base">{viz.chartSpec?.title || t('visualizationResult')}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center justify-between mb-3">
@@ -121,7 +123,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
<DialogHeader className="flex flex-row items-start justify-between pr-8">
<div>
<DialogTitle>Generated SQL Query</DialogTitle>
<DialogDescription className="mt-1"></DialogDescription>
<DialogDescription className="mt-1">{t('sqlQueryDescription')}</DialogDescription>
</div>
<Button
variant="outline"
@@ -132,12 +134,12 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-emerald-500" />
<span></span>
<span>{t('copied')}</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
<span></span>
<span>{t('copy')}</span>
</>
)}
</Button>
@@ -176,7 +178,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
<VegaChart data={objectRows} spec={viz.chartSpec} />
</div>
) : (
<div className="text-sm text-zinc-500"></div>
<div className="text-sm text-zinc-500">{t('resultNotSuitableForChart')}</div>
)
) : objectRows.length > 0 ? (
<ScrollArea className="h-80 border rounded-md">
@@ -198,15 +200,15 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
</Table>
</ScrollArea>
) : (
<div className="text-sm text-zinc-500"></div>
<div className="text-sm text-zinc-500">{t('noStructuredDataToRender')}</div>
)}
</CardContent>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle> Dashboard</DialogTitle>
<DialogTitle>{t('confirmAddToDashboard')}</DialogTitle>
<DialogDescription>
Dashboard
{t('confirmAddChartToDashboardDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -217,10 +219,10 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
setPendingChart(null);
}}
>
{t('cancel')}
</Button>
<Button onClick={handleConfirmAdd}>
{t('confirmAdd')}
</Button>
</DialogFooter>
</DialogContent>
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { ChevronDown, Plus, Folder } from 'lucide-react';
import { useProjectStore, type Project } from '@/store/projectStore';
import { useProjectStore } from '@/store/projectStore';
import {
DropdownMenu,
DropdownMenuContent,
+44 -30
View File
@@ -1,9 +1,10 @@
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, Folder } from "lucide-react";
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2, Folder, Globe } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/store/authStore";
import { api } from "@/lib/api";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
@@ -45,6 +46,7 @@ function Section({
onBatchDelete: (keys: string[]) => void;
activeKey: string | null;
}) {
const { t } = useTranslation();
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
const [isSelectionMode, setIsSelectionMode] = useState(false);
@@ -96,14 +98,14 @@ function Section({
<>
<button
onClick={handleSelectAll}
title="全选/取消全选"
title={t('selectAllOrCancel')}
className="p-1 hover:bg-zinc-200 rounded text-zinc-500 transition-colors"
>
<ListChecks className="h-3.5 w-3.5" />
</button>
<button
onClick={handleInvertSelection}
title="反选"
title={t('invertSelection')}
className="p-1 hover:bg-zinc-200 rounded text-zinc-500 transition-colors"
>
<RotateCcw className="h-3.5 w-3.5" />
@@ -111,7 +113,7 @@ function Section({
<button
onClick={handleBatchDelete}
disabled={selectedKeys.length === 0}
title="批量删除"
title={t('batchDelete')}
className={`p-1 rounded transition-colors ${
selectedKeys.length > 0
? "hover:bg-red-100 text-red-500"
@@ -124,7 +126,7 @@ function Section({
onClick={() => setIsSelectionMode(false)}
className="text-[10px] font-medium px-1.5 py-0.5 hover:bg-zinc-200 rounded text-zinc-500 transition-colors ml-1"
>
{t('cancel')}
</button>
</>
) : (
@@ -191,7 +193,7 @@ function Section({
}}
>
<Pencil className="mr-2 h-4 w-4" />
<span></span>
<span>{t('rename')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -206,7 +208,7 @@ function Section({
}}
>
<Pin className="mr-2 h-4 w-4" />
<span>{item.pinned ? "取消置顶" : "置顶"}</span>
<span>{item.pinned ? t('unpin') : t('pin')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -221,7 +223,7 @@ function Section({
}}
>
<Archive className="mr-2 h-4 w-4" />
<span>{item.archived ? "取消归档" : "归档"}</span>
<span>{item.archived ? t('unarchive') : t('archive')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
@@ -237,7 +239,7 @@ function Section({
className="text-red-600 focus:text-red-600 focus:bg-red-50"
>
<Trash2 className="mr-2 h-4 w-4" />
<span></span>
<span>{t('deleteSession')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -254,6 +256,7 @@ function SidebarBody() {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const { t, i18n } = useTranslation();
const [showUserMenu, setShowUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -324,7 +327,7 @@ function SidebarBody() {
};
const handleDeleteSession = async (key: string) => {
if (!window.confirm("确定要删除这个会话吗?")) return;
if (!window.confirm(t('confirmDeleteSession'))) return;
try {
await api.delete(`/nanobot/sessions/${encodeURIComponent(key)}`);
if (activeSessionKey === key) {
@@ -338,7 +341,7 @@ function SidebarBody() {
};
const handleBatchDelete = async (keys: string[]) => {
if (!window.confirm(`确定要删除选中的 ${keys.length} 个会话吗?`)) return;
if (!window.confirm(t('confirmBatchDeleteSessions', { count: keys.length }))) return;
try {
await api.post("/nanobot/sessions/batch-delete", { session_ids: keys });
if (keys.includes(activeSessionKey)) {
@@ -444,7 +447,7 @@ function SidebarBody() {
<Link to="/" className="flex items-center gap-1.5 text-zinc-700 font-bold text-lg hover:opacity-80 transition-opacity">
<span className="text-xl leading-none mr-0.5">🦞</span>
<span className="bg-clip-text text-transparent bg-gradient-to-r from-zinc-800 to-zinc-600">
{t('lobsterDataQA')}
</span>
</Link>
<div className="w-8" />
@@ -457,7 +460,7 @@ function SidebarBody() {
onClick={() => navigate("/dashboard")}
>
<LayoutDashboard className="h-4.5 w-4.5 mr-2 text-zinc-600" />
Dashboard
{t('dashboardMenu')}
</Button>
<Button
@@ -466,7 +469,7 @@ function SidebarBody() {
onClick={handleNewThread}
>
<Plus className="h-4 w-4 mr-2" />
New Thread
{t('newThread')}
</Button>
</div>
@@ -477,13 +480,13 @@ function SidebarBody() {
<Input
value={sessionFilter}
onChange={(e) => setSessionFilter(e.target.value)}
placeholder="过滤会话名称"
placeholder={t('filterSessionName')}
className="pl-9 h-9 border-zinc-200 bg-white"
/>
</div>
</div>
<Section
title="THREADS"
title={t('threads')}
count={activeSessions.length}
items={activeSessions}
onSelect={handleSelectSession}
@@ -495,7 +498,7 @@ function SidebarBody() {
activeKey={activeSessionKey}
/>
<Section
title="ARCHIVED_THREADS"
title={t('archivedThreads')}
count={archivedSessions.length}
items={archivedSessions}
onSelect={handleSelectSession}
@@ -511,13 +514,13 @@ function SidebarBody() {
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogTitle>{t('renameSession')}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="输入新的会话标题"
placeholder={t('enterNewSessionTitle')}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
@@ -527,8 +530,8 @@ function SidebarBody() {
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}></Button>
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white" onClick={handleRename}></Button>
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>{t('cancel')}</Button>
<Button className="bg-indigo-600 hover:bg-indigo-700 text-white" onClick={handleRename}>{t('save')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -543,7 +546,7 @@ function SidebarBody() {
<User className="h-4.5 w-4.5" />
</div>
<div className="text-sm font-medium truncate max-w-[100px] text-left">
{user?.username || 'User'}
{user?.username || t('defaultUser')}
</div>
</button>
@@ -552,7 +555,7 @@ function SidebarBody() {
onClick={() => navigate("/skills")}
>
<Wand2 className="h-4 w-4" />
{t('skillCenter')}
</button>
</div>
@@ -572,7 +575,7 @@ function SidebarBody() {
}}
>
<Folder className="h-4 w-4 text-zinc-500" />
{t('projectManagement')}
</button>
<button
@@ -583,7 +586,7 @@ function SidebarBody() {
}}
>
<Database className="h-4 w-4 text-zinc-500" />
{t('dataSourceManagement')}
</button>
<button
@@ -594,7 +597,7 @@ function SidebarBody() {
}}
>
<Settings className="h-4 w-4 text-zinc-500" />
{t('personalSettings')}
</button>
{user?.is_admin && (
@@ -607,29 +610,40 @@ function SidebarBody() {
}}
>
<Brain className="h-4 w-4 text-zinc-500" />
{t('modelConfig')}
</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"
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("/users");
setShowUserMenu(false);
}}
>
<User className="h-4 w-4" />
{t('userManagement')}
</button>
</>
)}
<div className="h-px bg-zinc-100 my-1 mx-2" />
<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={() => {
i18n.changeLanguage(i18n.language === 'zh' ? 'en' : 'zh');
setShowUserMenu(false);
}}
>
<Globe className="h-4 w-4 text-zinc-500" />
{i18n.language === 'zh' ? 'English' : '中文'}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
onClick={handleLogout}
>
退
{t('logout')}
</button>
</div>
)}
+4 -2
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from "@/lib/utils";
interface Skill {
@@ -17,6 +18,7 @@ interface SlashCommandMenuProps {
}
export function SlashCommandMenu({ isOpen, skills, selectedIndex, onSelect, onClose }: SlashCommandMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLButtonElement>(null);
@@ -60,7 +62,7 @@ export function SlashCommandMenu({ isOpen, skills, selectedIndex, onSelect, onCl
)}
>
<span className="font-bold text-blue-400 shrink-0 font-mono">/{skill.name}</span>
<span className="text-zinc-400 truncate text-xs">{skill.description || "无描述"}</span>
<span className="text-zinc-400 truncate text-xs">{skill.description || t('noDescription')}</span>
</button>
))}
</div>
@@ -8,9 +8,11 @@ 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 { useTranslation } from "react-i18next";
import { VegaChart } from "./VegaChart";
export function VisualizationPanel() {
const { t } = useTranslation();
const [view, setView] = useState<'table' | 'chart'>('chart');
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
@@ -206,9 +208,9 @@ export function VisualizationPanel() {
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle> Dashboard</DialogTitle>
<DialogTitle>{t('confirmAddToDashboard')}</DialogTitle>
<DialogDescription>
Dashboard
{t('confirmAddChartToDashboardDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -219,9 +221,9 @@ export function VisualizationPanel() {
setPendingChart(null);
}}
>
{t('cancel')}
</Button>
<Button onClick={handleConfirmAdd}></Button>
<Button onClick={handleConfirmAdd}>{t('confirmAdd')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>