feat(ui): i18n for chat conversation list and MCP panels

This commit is contained in:
吹雪 2026-06-11 04:12:45 +09:00
parent ae6818f83c
commit caf77c8419
6 changed files with 244 additions and 74 deletions

View file

@ -11,6 +11,7 @@ import {
MessageOutlined,
} from "@ant-design/icons";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { Conversation } from "./types";
const { Text } = Typography;
@ -71,6 +72,7 @@ interface ConversationRowProps {
}
const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSelect, onDelete, onRename }) => {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(conv.title);
const inputRef = useRef<HTMLInputElement | null>(null);
@ -181,7 +183,7 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
}}
onClick={(e) => e.stopPropagation()}
>
<Tooltip title="Rename">
<Tooltip title={t("chat.conversationList.rename")}>
<Button
type="text"
size="small"
@ -191,13 +193,13 @@ const ConversationRow: React.FC<ConversationRowProps> = ({ conv, isActive, onSel
/>
</Tooltip>
<Popconfirm
title="Delete this conversation?"
title={t("chat.conversationList.deleteConversationTitle")}
onConfirm={() => onDelete(conv.id)}
okText="Delete"
cancelText="Cancel"
okText={t("common.delete")}
cancelText={t("common.cancel")}
okButtonProps={{ danger: true }}
>
<Tooltip title="Delete">
<Tooltip title={t("common.delete")}>
<Button
type="text"
size="small"
@ -224,6 +226,7 @@ interface SearchModalProps {
}
const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect, onClose }) => {
const { t } = useTranslation();
const [query, setQuery] = useState("");
useEffect(() => {
@ -251,7 +254,7 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect
<Input
autoFocus
prefix={<SearchOutlined style={{ color: "#bbb" }} />}
placeholder="Search conversations…"
placeholder={t("chat.conversationList.searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ marginBottom: 12 }}
@ -260,7 +263,9 @@ const SearchModal: React.FC<SearchModalProps> = ({ open, conversations, onSelect
<div style={{ maxHeight: 320, overflowY: "auto" }}>
{filtered.length === 0 ? (
<div style={{ textAlign: "center", padding: "24px 0", color: "#999" }}>No conversations found</div>
<div style={{ textAlign: "center", padding: "24px 0", color: "#999" }}>
{t("chat.conversationList.noConversationsFound")}
</div>
) : (
filtered.map((conv) => {
const truncated = conv.title.length > 55 ? conv.title.slice(0, 55) + "…" : conv.title;
@ -308,8 +313,19 @@ const ConversationList: React.FC<Props> = ({
onNewChat,
onRename,
}) => {
const { t } = useTranslation();
const [searchModalOpen, setSearchModalOpen] = useState(false);
const dateGroupLabel = (group: DateGroup): string => {
const keyMap: Record<DateGroup, string> = {
Today: "chat.conversationList.dateGroupToday",
Yesterday: "chat.conversationList.dateGroupYesterday",
"Last 7 Days": "chat.conversationList.dateGroupLast7Days",
Older: "chat.conversationList.dateGroupOlder",
};
return t(keyMap[group]);
};
// Cmd+K / Ctrl+K listener
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@ -345,12 +361,9 @@ const ConversationList: React.FC<Props> = ({
>
{/* Top: New Chat button */}
<div style={{ padding: "12px 10px 8px" }}>
<Tooltip
title="Chats are saved locally in this browser. All requests are logged in Spend → Logs."
placement="right"
>
<Tooltip title={t("chat.conversationList.chatsLocalStorageTooltip")} placement="right">
<Button type="primary" icon={<PlusOutlined />} onClick={onNewChat} style={{ width: "100%" }}>
New Chat
{t("chat.conversationList.newChat")}
</Button>
</Tooltip>
</div>
@ -371,11 +384,10 @@ const ConversationList: React.FC<Props> = ({
fontSize: 12,
marginTop: 32,
padding: "0 12px",
whiteSpace: "pre-line",
}}
>
No conversations yet.
<br />
Start a new chat above.
{t("chat.conversationList.noConversationsYet")}
</div>
) : (
grouped.map(({ group, items }) => (
@ -390,7 +402,7 @@ const ConversationList: React.FC<Props> = ({
padding: "8px 8px 4px",
}}
>
{group}
{dateGroupLabel(group)}
</div>
{items.map((conv) => (
<ConversationRow
@ -431,7 +443,7 @@ const ConversationList: React.FC<Props> = ({
textOverflow: "ellipsis",
}}
>
My Account
{t("chat.conversationList.myAccount")}
</Text>
</div>
</div>

View file

@ -3,6 +3,7 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Spin, Input, Button, Skeleton } from "antd";
import { SearchOutlined, ArrowLeftOutlined, RightOutlined, ToolOutlined, CheckCircleOutlined } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import {
deleteMCPOAuthUserCredential,
fetchMCPServers,
@ -29,6 +30,7 @@ const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
onConnect,
variant = "badge",
}) => {
const { t } = useTranslation();
const name = server.server_name ?? server.alias ?? server.server_id;
const { startOAuthFlow, status } = useUserMcpOAuthFlow({
accessToken,
@ -47,7 +49,7 @@ const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
onClick={startOAuthFlow}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
{loading ? "Connecting…" : "Connect"}
{loading ? t("chat.mCPAppsPanel.connecting") : t("chat.mCPAppsPanel.connect")}
</Button>
);
}
@ -70,7 +72,7 @@ const OAuth2ConnectButton: React.FC<OAuth2ConnectButtonProps> = ({
whiteSpace: "nowrap",
}}
>
{loading ? "Connecting…" : "Connect"}
{loading ? t("chat.mCPAppsPanel.connecting") : t("chat.mCPAppsPanel.connect")}
</span>
);
};
@ -104,6 +106,7 @@ function getAvatarColor(name: string): string {
type TabKey = "all" | "connected";
const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const { t } = useTranslation();
const [servers, setServers] = useState<MCPServer[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
@ -228,7 +231,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
const idToFetch = serverId ?? serverName;
const result = await listMCPTools(accessToken, idToFetch);
if (result?.error) {
MessageManager.warning(`Could not load tools for ${serverName}`);
MessageManager.warning(t("chat.mCPAppsPanel.couldNotLoadTools", { name: serverName }));
return;
}
// Use the ref so we read the most up-to-date list; guard against duplicates
@ -237,7 +240,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
onChange([...selectedServersRef.current, serverName]);
}
} catch {
MessageManager.warning(`Could not load tools for ${serverName}`);
MessageManager.warning(t("chat.mCPAppsPanel.couldNotLoadTools", { name: serverName }));
} finally {
setTogglingOn((prev) => {
const next = new Set(prev);
@ -313,7 +316,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
}}
>
<ArrowLeftOutlined style={{ fontSize: 12 }} />
Back
{t("common.back")}
</button>
{/* Avatar + name + connect */}
@ -321,7 +324,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
{detailServer.mcp_info?.logo_url ? (
<img
src={detailServer.mcp_info.logo_url}
alt={`${name} logo`}
alt={t("chat.mCPAppsPanel.logoAlt", { name })}
style={{
width: 64,
height: 64,
@ -356,7 +359,9 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
</div>
<div style={{ flex: 1 }}>
<h2 style={{ margin: "0 0 4px", fontSize: 22, fontWeight: 700, color: "#111827" }}>{name}</h2>
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>{detailServer.description ?? "MCP server"}</p>
<p style={{ margin: 0, fontSize: 14, color: "#6b7280" }}>
{detailServer.description ?? t("chat.mCPAppsPanel.mcpServerFallbackDesc")}
</p>
</div>
{detailServer.auth_type === AUTH_TYPE.OAUTH2 ? (
oauthConnected.has(detailServer.server_id) ? (
@ -378,7 +383,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
}}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
Disconnect
{t("chat.mCPAppsPanel.disconnect")}
</Button>
) : (
<OAuth2ConnectButton
@ -397,18 +402,20 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
onClick={() => handleToggle(name, !isConnected, detailServer.server_id)}
style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }}
>
{isConnected ? "Disconnect" : "Connect"}
{isConnected ? t("chat.mCPAppsPanel.disconnect") : t("chat.mCPAppsPanel.connect")}
</Button>
)}
</div>
{/* Info table */}
<h3 style={{ margin: "0 0 12px", fontSize: 15, fontWeight: 600, color: "#111827" }}>Information</h3>
<h3 style={{ margin: "0 0 12px", fontSize: 15, fontWeight: 600, color: "#111827" }}>
{t("chat.mCPAppsPanel.information")}
</h3>
<div style={{ border: "1px solid #e5e7eb", borderRadius: 10, overflow: "hidden", marginBottom: 28 }}>
{[
["Server ID", detailServer.server_id],
["Transport", handleTransport(detailServer.transport, detailServer.spec_path)],
["Status", isConnected ? "Connected" : "Not connected"],
[t("chat.mCPAppsPanel.serverIdLabel"), detailServer.server_id],
[t("chat.mCPAppsPanel.transportLabel"), handleTransport(detailServer.transport, detailServer.spec_path)],
[t("common.status"), isConnected ? t("chat.mCPAppsPanel.connected") : t("chat.mCPAppsPanel.notConnected")],
]
.filter(([, v]) => v)
.map(([label, value], i, arr) => (
@ -429,7 +436,9 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
{/* Tools section */}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: "#111827" }}>Available Tools</h3>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: "#111827" }}>
{t("chat.mCPAppsPanel.availableTools")}
</h3>
{!loadingTools && (
<span
style={{
@ -450,7 +459,9 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
<Spin size="small" />
</div>
) : detailTools.length === 0 ? (
<div style={{ color: "#9ca3af", fontSize: 13, padding: "8px 0" }}>No tools available</div>
<div style={{ color: "#9ca3af", fontSize: 13, padding: "8px 0" }}>
{t("chat.mCPAppsPanel.noToolsAvailable")}
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{detailTools.map((tool) => (
@ -496,7 +507,9 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#111827" }}>MCP Servers</h2>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: "#111827" }}>
{t("chat.mCPAppsPanel.mcpServersTitle")}
</h2>
<span
style={{
fontSize: 10,
@ -509,29 +522,27 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
textTransform: "uppercase",
}}
>
Beta
{t("chat.mCPAppsPanel.beta")}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<p style={{ margin: 0, fontSize: 13, color: "#6b7280" }}>
Browse tools, authenticate once, use in chat — no setup needed.
</p>
<p style={{ margin: 0, fontSize: 13, color: "#6b7280" }}>{t("chat.mCPAppsPanel.browsePitch")}</p>
{loadingCounts ? (
<span style={{ display: "flex", alignItems: "center", gap: 5, fontSize: 12, color: "#9ca3af" }}>
<Spin size="small" style={{ transform: "scale(0.7)" }} />
Loading tools...
{t("chat.mCPAppsPanel.loadingTools")}
</span>
) : totalTools > 0 ? (
<span style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 12, color: "#6b7280" }}>
<ToolOutlined style={{ fontSize: 11 }} />
{totalTools} tool{totalTools !== 1 ? "s" : ""} available
{t("chat.mCPAppsPanel.toolsAvailable", { count: totalTools })}
</span>
) : null}
</div>
</div>
<Input
prefix={<SearchOutlined style={{ color: "#9ca3af", fontSize: 13 }} />}
placeholder="Search servers..."
placeholder={t("chat.mCPAppsPanel.searchServersPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
allowClear
@ -558,7 +569,11 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
marginBottom: -1,
}}
>
{tab === "all" ? "All" : `Connected${connectedCount > 0 ? ` (${connectedCount})` : ""}`}
{tab === "all"
? t("chat.mCPAppsPanel.tabAll")
: connectedCount > 0
? t("chat.mCPAppsPanel.tabConnectedCount", { count: connectedCount })
: t("chat.mCPAppsPanel.tabConnected")}
</button>
))}
</div>
@ -571,10 +586,10 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
) : filtered.length === 0 ? (
<div style={{ textAlign: "center", color: "#9ca3af", fontSize: 13, padding: "48px 12px" }}>
{servers.length === 0
? "No MCP servers configured. Add servers in Tools → MCP Servers."
? t("chat.mCPAppsPanel.noServersConfigured")
: activeTab === "connected"
? "No servers connected yet."
: "No servers match your search."}
? t("chat.mCPAppsPanel.noServersConnected")
: t("chat.mCPAppsPanel.noServersMatch")}
</div>
) : (
<div
@ -621,7 +636,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
{server.mcp_info?.logo_url ? (
<img
src={server.mcp_info.logo_url}
alt={`${name} logo`}
alt={t("chat.mCPAppsPanel.logoAlt", { name })}
style={{
width: 38,
height: 38,
@ -678,7 +693,7 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
}}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{server.description ?? "MCP server"}
{server.description ?? t("chat.mCPAppsPanel.mcpServerFallbackDesc")}
</span>
{count !== undefined ? (
count > 0 ? (

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Switch, Spin } from "antd";
import { useTranslation } from "react-i18next";
import MessageManager from "@/components/molecules/message_manager";
import { fetchMCPServers, listMCPTools } from "../networking";
import { MCPServer } from "../mcp_tools/types";
@ -11,6 +12,7 @@ interface Props {
}
const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onChange }) => {
const { t } = useTranslation();
const [servers, setServers] = useState<MCPServer[]>([]);
const [loadingServers, setLoadingServers] = useState(true);
// Track which individual servers are being toggled on (verifying tools)
@ -58,13 +60,13 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
const result = await listMCPTools(accessToken, serverName);
// listMCPTools never throws; it returns { tools, error, message } on failure
if (result?.error) {
MessageManager.warning(`Could not load tools for ${serverName} — it will be excluded from this message.`);
MessageManager.warning(t("chat.mCPConnectPicker.couldNotLoadTools", { name: serverName }));
// Do not add to selectedServers
return;
}
onChange([...selectedServers, serverName]);
} catch {
MessageManager.warning(`Could not load tools for ${serverName} — it will be excluded from this message.`);
MessageManager.warning(t("chat.mCPConnectPicker.couldNotLoadTools", { name: serverName }));
// Do not add to selectedServers
} finally {
setTogglingOn((prev) => {
@ -90,7 +92,7 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
</div>
) : servers.length === 0 ? (
<div style={{ padding: "16px 12px", color: "#8c8c8c", fontSize: 13, textAlign: "center" }}>
No MCP servers configured
{t("chat.mCPConnectPicker.noServersConfigured")}
</div>
) : (
servers.map((server) => {
@ -112,7 +114,7 @@ const MCPConnectPicker: React.FC<Props> = ({ accessToken, selectedServers, onCha
{server.mcp_info?.logo_url && (
<img
src={server.mcp_info.logo_url}
alt={`${name} logo`}
alt={t("chat.mCPConnectPicker.logoAlt", { name })}
style={{
width: 24,
height: 24,

View file

@ -9,6 +9,8 @@
import React, { useCallback, useEffect, useState } from "react";
import { Spin } from "antd";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import MessageManager from "@/components/molecules/message_manager";
import { DeleteOutlined, LinkOutlined } from "@ant-design/icons";
import { Badge, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
@ -18,42 +20,43 @@ interface Props {
accessToken: string;
}
function relativeTime(isoString: string | null | undefined): string {
function relativeTime(isoString: string | null | undefined, t: TFunction): string {
if (!isoString) return "";
try {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return "just now";
if (diffSec < 60) return t("chat.mCPCredentialsTab.justNow");
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 60) return t("chat.mCPCredentialsTab.minutesAgo", { count: diffMin });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.floor(diffHr / 24)}d ago`;
if (diffHr < 24) return t("chat.mCPCredentialsTab.hoursAgo", { count: diffHr });
return t("chat.mCPCredentialsTab.daysAgo", { count: Math.floor(diffHr / 24) });
} catch {
return "";
}
}
function expiryLabel(isoString: string | null | undefined): string {
if (!isoString) return "Does not expire";
function expiryLabel(isoString: string | null | undefined, t: TFunction): string {
if (!isoString) return t("chat.mCPCredentialsTab.doesNotExpire");
try {
const exp = new Date(isoString);
const diffMs = exp.getTime() - Date.now();
if (diffMs <= 0) return "Expired";
if (diffMs <= 0) return t("chat.mCPCredentialsTab.expired");
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffDay > 0) return `Expires in ${diffDay}d`;
if (diffHr > 0) return `Expires in ${diffHr}h`;
return `Expires in ${diffMin}m`;
if (diffDay > 0) return t("chat.mCPCredentialsTab.expiresInDays", { count: diffDay });
if (diffHr > 0) return t("chat.mCPCredentialsTab.expiresInHours", { count: diffHr });
return t("chat.mCPCredentialsTab.expiresInMinutes", { count: diffMin });
} catch {
return "";
}
}
const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<MCPUserCredentialListItem[]>([]);
const [loading, setLoading] = useState(true);
const [revoking, setRevoking] = useState<Set<string>>(new Set());
@ -76,7 +79,7 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
await deleteMCPOAuthUserCredential(accessToken, serverId);
setCredentials((prev) => prev.filter((c) => c.server_id !== serverId));
} catch {
MessageManager.error("Failed to revoke connection. Please try again.");
MessageManager.error(t("chat.mCPCredentialsTab.revokeError"));
} finally {
setRevoking((prev) => {
const n = new Set(prev);
@ -92,8 +95,8 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
<div className="w-full">
{/* Header */}
<div className="mb-4">
<h2 className="text-base font-semibold text-gray-900 mb-0.5">App Credentials</h2>
<p className="text-sm text-gray-500 m-0">Your stored OAuth connections — used automatically in chat.</p>
<h2 className="text-base font-semibold text-gray-900 mb-0.5">{t("chat.mCPCredentialsTab.appCredentials")}</h2>
<p className="text-sm text-gray-500 m-0">{t("chat.mCPCredentialsTab.oauthConnectionsDesc")}</p>
</div>
{loading ? (
@ -103,20 +106,26 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
) : credentials.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-12 border border-dashed border-gray-200 rounded-lg">
<LinkOutlined className="text-2xl mb-3 block text-gray-300" />
No connections yet.
{t("chat.mCPCredentialsTab.noConnectionsYet")}
<br />
Go to <strong>Apps</strong> and click <strong>Connect</strong> to authorize an MCP server.
<Trans i18nKey="chat.mCPCredentialsTab.goToAppsConnect" components={{ strong: <strong /> }} />
</div>
) : (
<div className="rounded-lg border border-gray-200 overflow-hidden">
<Table>
<TableHead>
<TableRow>
<TableHeaderCell className="text-xs font-medium text-gray-500 py-2 px-4">App</TableHeaderCell>
<TableHeaderCell className="text-xs font-medium text-gray-500 py-2 px-4">Connected</TableHeaderCell>
<TableHeaderCell className="text-xs font-medium text-gray-500 py-2 px-4">Status</TableHeaderCell>
<TableHeaderCell className="text-xs font-medium text-gray-500 py-2 px-4">
{t("chat.mCPCredentialsTab.colApp")}
</TableHeaderCell>
<TableHeaderCell className="text-xs font-medium text-gray-500 py-2 px-4">
{t("chat.mCPCredentialsTab.colConnected")}
</TableHeaderCell>
<TableHeaderCell className="text-xs font-medium text-gray-500 py-2 px-4">
{t("common.status")}
</TableHeaderCell>
<TableHeaderCell className="text-xs font-medium text-gray-500 py-2 px-4 text-right">
Actions
{t("common.actions")}
</TableHeaderCell>
</TableRow>
</TableHead>
@ -124,9 +133,9 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
{credentials.map((cred) => {
const name = displayName(cred);
const isRevoking = revoking.has(cred.server_id);
const exp = expiryLabel(cred.expires_at);
const connected = relativeTime(cred.connected_at);
const isExpired = exp === "Expired";
const exp = expiryLabel(cred.expires_at, t);
const connected = relativeTime(cred.connected_at, t);
const isExpired = exp === t("chat.mCPCredentialsTab.expired");
return (
<TableRow key={cred.server_id} className="h-10 hover:bg-gray-50">
@ -145,7 +154,7 @@ const MCPCredentialsTab: React.FC<Props> = ({ accessToken }) => {
<button
onClick={() => handleRevoke(cred.server_id)}
disabled={isRevoking}
title="Revoke connection"
title={t("chat.mCPCredentialsTab.revokeConnection")}
className={`inline-flex items-center justify-center rounded-md border border-gray-200 px-2 py-1 text-gray-400 hover:text-red-500 hover:border-red-200 transition-colors ${isRevoking ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
style={{ background: "none" }}
>

View file

@ -6930,6 +6930,72 @@
"responseN": "Response {{n}}",
"generating": "Generating…",
"scrollToBottom": "Scroll to bottom"
},
"conversationList": {
"rename": "Rename",
"deleteConversationTitle": "Delete this conversation?",
"searchPlaceholder": "Search conversations…",
"noConversationsFound": "No conversations found",
"chatsLocalStorageTooltip": "Chats are saved locally in this browser. All requests are logged in Spend → Logs.",
"newChat": "New Chat",
"noConversationsYet": "No conversations yet.\nStart a new chat above.",
"myAccount": "My Account",
"dateGroupToday": "Today",
"dateGroupYesterday": "Yesterday",
"dateGroupLast7Days": "Last 7 Days",
"dateGroupOlder": "Older"
},
"mCPAppsPanel": {
"connecting": "Connecting…",
"connect": "Connect",
"disconnect": "Disconnect",
"mcpServerFallbackDesc": "MCP server",
"information": "Information",
"serverIdLabel": "Server ID",
"transportLabel": "Transport",
"connected": "Connected",
"notConnected": "Not connected",
"availableTools": "Available Tools",
"noToolsAvailable": "No tools available",
"mcpServersTitle": "MCP Servers",
"beta": "Beta",
"browsePitch": "Browse tools, authenticate once, use in chat — no setup needed.",
"loadingTools": "Loading tools...",
"toolsAvailable_one": "{{count}} tool available",
"toolsAvailable_other": "{{count}} tools available",
"searchServersPlaceholder": "Search servers...",
"tabAll": "All",
"tabConnected": "Connected",
"tabConnectedCount": "Connected ({{count}})",
"noServersConfigured": "No MCP servers configured. Add servers in Tools → MCP Servers.",
"noServersConnected": "No servers connected yet.",
"noServersMatch": "No servers match your search.",
"couldNotLoadTools": "Could not load tools for {{name}}",
"logoAlt": "{{name}} logo"
},
"mCPConnectPicker": {
"noServersConfigured": "No MCP servers configured",
"couldNotLoadTools": "Could not load tools for {{name}} — it will be excluded from this message.",
"logoAlt": "{{name}} logo"
},
"mCPCredentialsTab": {
"justNow": "just now",
"minutesAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"daysAgo": "{{count}}d ago",
"doesNotExpire": "Does not expire",
"expired": "Expired",
"expiresInDays": "Expires in {{count}}d",
"expiresInHours": "Expires in {{count}}h",
"expiresInMinutes": "Expires in {{count}}m",
"appCredentials": "App Credentials",
"oauthConnectionsDesc": "Your stored OAuth connections — used automatically in chat.",
"noConnectionsYet": "No connections yet.",
"goToAppsConnect": "Go to <strong>Apps</strong> and click <strong>Connect</strong> to authorize an MCP server.",
"colApp": "App",
"colConnected": "Connected",
"revokeConnection": "Revoke connection",
"revokeError": "Failed to revoke connection. Please try again."
}
}
}

View file

@ -6930,6 +6930,72 @@
"responseN": "响应 {{n}}",
"generating": "生成中…",
"scrollToBottom": "滚动到底部"
},
"conversationList": {
"rename": "重命名",
"deleteConversationTitle": "删除此对话?",
"searchPlaceholder": "搜索对话…",
"noConversationsFound": "未找到对话",
"chatsLocalStorageTooltip": "对话已保存在此浏览器本地。所有请求均记录在花费 → 日志中。",
"newChat": "新建对话",
"noConversationsYet": "暂无对话。\n点击上方新建对话。",
"myAccount": "我的账号",
"dateGroupToday": "今天",
"dateGroupYesterday": "昨天",
"dateGroupLast7Days": "最近 7 天",
"dateGroupOlder": "更早"
},
"mCPAppsPanel": {
"connecting": "连接中…",
"connect": "连接",
"disconnect": "断开连接",
"mcpServerFallbackDesc": "MCP 服务器",
"information": "信息",
"serverIdLabel": "服务器 ID",
"transportLabel": "传输方式",
"connected": "已连接",
"notConnected": "未连接",
"availableTools": "可用工具",
"noToolsAvailable": "无可用工具",
"mcpServersTitle": "MCP 服务器",
"beta": "Beta",
"browsePitch": "浏览工具,一次认证,在对话中使用 — 无需额外配置。",
"loadingTools": "加载工具中...",
"toolsAvailable_one": "{{count}} 个工具可用",
"toolsAvailable_other": "{{count}} 个工具可用",
"searchServersPlaceholder": "搜索服务器...",
"tabAll": "全部",
"tabConnected": "已连接",
"tabConnectedCount": "已连接 ({{count}})",
"noServersConfigured": "未配置 MCP 服务器。请在工具 → MCP 服务器中添加。",
"noServersConnected": "暂无已连接的服务器。",
"noServersMatch": "没有服务器匹配您的搜索。",
"couldNotLoadTools": "无法加载 {{name}} 的工具",
"logoAlt": "{{name}} 图标"
},
"mCPConnectPicker": {
"noServersConfigured": "未配置 MCP 服务器",
"couldNotLoadTools": "无法加载 {{name}} 的工具 — 该服务器将不参与本次请求。",
"logoAlt": "{{name}} 图标"
},
"mCPCredentialsTab": {
"justNow": "刚刚",
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"daysAgo": "{{count}} 天前",
"doesNotExpire": "永不过期",
"expired": "已过期",
"expiresInDays": "{{count}} 天后过期",
"expiresInHours": "{{count}} 小时后过期",
"expiresInMinutes": "{{count}} 分钟后过期",
"appCredentials": "应用凭证",
"oauthConnectionsDesc": "您存储的 OAuth 连接 — 在对话中自动使用。",
"noConnectionsYet": "暂无连接。",
"goToAppsConnect": "前往 <strong>应用</strong>,点击 <strong>连接</strong> 以授权 MCP 服务器。",
"colApp": "应用",
"colConnected": "连接时间",
"revokeConnection": "撤销连接",
"revokeError": "撤销连接失败,请重试。"
}
}
}