Studio: bulk export and import in Settings Chat Data, MCP pill off switch (#6141)
* Studio: bulk export and import in Settings Chat Data, MCP pill off switch - Settings -> Chat -> Data gains Export Recents and Projects (Recents or Recents + Projects, Raw JSONL / CSV / ShareGPT, combined or per chat) and Import chats, reusing the sidebar Recents menu actions - Extract bulkExportConversationsByScope so the sidebar and Settings share one implementation; expose the export and import helpers via the chat feature index - MCP composer pill icon now swaps to an X on hover like Search, Code and RAG; clicking it turns MCP off without opening the server menu - en and zh-CN locale strings added (parity check passes) * Reveal the pill X on hover for off-switch icons regardless of active look The X was gated on data-active, so an MCP pill with no servers enabled (or a RAG pill without a model) closed on icon click but never showed the affordance. Off-switch pills only render while their feature is on, so hover now always reveals the X. * Replace the Recents hamburger menu with an Export all chats link to Settings Bulk export and import now live in Settings -> Chat -> Data, so the sidebar Recents header menu is gone. Each chat's Export submenu gains Export all chats, which opens Settings on the Chat tab.
This commit is contained in:
parent
62191c4765
commit
dfb49fd2cb
8 changed files with 233 additions and 115 deletions
|
|
@ -51,7 +51,6 @@ import {
|
|||
Delete02Icon,
|
||||
Download01Icon,
|
||||
DownloadSquare01Icon,
|
||||
Upload01Icon,
|
||||
Edit03Icon,
|
||||
FolderAddIcon,
|
||||
FolderExportIcon,
|
||||
|
|
@ -72,11 +71,6 @@ import {
|
|||
exportConversationRawJsonl,
|
||||
exportConversationCsv,
|
||||
exportConversationShareGPT,
|
||||
exportBulkConversationsMerged,
|
||||
exportBulkConversationsSeparate,
|
||||
importConversationsFromFile,
|
||||
EXPORT_FORMATS_LIST,
|
||||
type ConvExportFormat,
|
||||
} from "@/features/chat/prompt-storage/prompt-storage-dialog";
|
||||
import { listStoredChatThreads } from "@/features/chat/utils/chat-history-storage";
|
||||
import {
|
||||
|
|
@ -259,40 +253,6 @@ export function AppSidebar() {
|
|||
const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/");
|
||||
const [chatOpen, setChatOpen] = useState(true);
|
||||
|
||||
const recentsImportInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleImportToRecents(file: File) {
|
||||
try {
|
||||
const count = await importConversationsFromFile(file, null);
|
||||
if (count === 0) {
|
||||
toast.info("No conversations found in file.");
|
||||
} else {
|
||||
toast.success(`Imported ${count} conversation${count === 1 ? "" : "s"} to Recents.`);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Import failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkExport(scope: "recents" | "all", fmt: ConvExportFormat, merged: boolean) {
|
||||
try {
|
||||
const threads = await listStoredChatThreads({
|
||||
includeArchived: false,
|
||||
...(scope === "recents" ? { projectId: null } : {}),
|
||||
});
|
||||
const ids = [...new Set(threads.map((t) => t.id))];
|
||||
if (ids.length === 0) { toast.info("No conversations to export."); return; }
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
const basename = scope === "all" ? `all-chats-${ts}` : `recents-${ts}`;
|
||||
if (merged) {
|
||||
await exportBulkConversationsMerged(ids, fmt, basename);
|
||||
} else {
|
||||
await exportBulkConversationsSeparate(ids, fmt, basename);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Export failed.");
|
||||
}
|
||||
}
|
||||
const [trainOpen, setTrainOpen] = useState(true);
|
||||
const [runsOpen, setRunsOpen] = useState(true);
|
||||
|
||||
|
|
@ -719,6 +679,15 @@ export function AppSidebar() {
|
|||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{/* Bulk export and import live in Settings -> Chat -> Data. */}
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
useSettingsDialogStore.getState().openDialog("chat")
|
||||
}
|
||||
>
|
||||
Export all chats…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
|
|
@ -736,18 +705,6 @@ export function AppSidebar() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden file inputs for chat import */}
|
||||
<input
|
||||
ref={recentsImportInputRef}
|
||||
type="file"
|
||||
accept=".jsonl,.ndjson,.csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleImportToRecents(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
variant="sidebar"
|
||||
|
|
@ -966,62 +923,6 @@ export function AppSidebar() {
|
|||
{t("shell.navigation.recents")}
|
||||
<ChevronDown className="size-3.5 opacity-0 transition-[transform,opacity] duration-200 group-hover/sb-collap:opacity-100 group-focus-visible/sb-collap:opacity-100 data-[state=open]:rotate-0 [[data-state=closed]_&]:rotate-[-90deg] [[data-state=closed]_&]:opacity-100" />
|
||||
</CollapsibleTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex items-center justify-center rounded-sm p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus-visible:ring-0"
|
||||
title="Export recents"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start" className="w-56">
|
||||
<DropdownMenuItem onSelect={() => recentsImportInputRef.current?.click()}>
|
||||
<HugeiconsIcon icon={Upload01Icon} strokeWidth={1.75} className="size-icon mr-1" />
|
||||
Import chats
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon mr-1" />
|
||||
Export Recents
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent avoidCollisions={false} className="unsloth-plus-menu w-52">
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`r-m-${fmt}`} onSelect={() => void handleBulkExport("recents", fmt, true)}>
|
||||
{label} — combined
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`r-s-${fmt}`} onSelect={() => void handleBulkExport("recents", fmt, false)}>
|
||||
{label} — per chat
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon icon={Download01Icon} strokeWidth={1.75} className="size-icon mr-1" />
|
||||
Export Recents + Projects
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent avoidCollisions={false} className="unsloth-plus-menu w-52">
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`a-m-${fmt}`} onSelect={() => void handleBulkExport("all", fmt, true)}>
|
||||
{label} — combined
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label }) => (
|
||||
<DropdownMenuItem key={`a-s-${fmt}`} onSelect={() => void handleBulkExport("all", fmt, false)}>
|
||||
{label} — per chat
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ export {
|
|||
useSelectedChatArtifact,
|
||||
} from "./artifacts/store";
|
||||
export { downloadChatExport } from "./utils/export-chat-history";
|
||||
export {
|
||||
EXPORT_FORMATS_LIST,
|
||||
bulkExportConversationsByScope,
|
||||
importConversationsFromFile,
|
||||
} from "./prompt-storage/prompt-storage-dialog";
|
||||
export {
|
||||
deleteChatItem,
|
||||
renameChatItem,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { McpServerIcon, Tick02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -248,11 +249,26 @@ export function McpComposerButton({
|
|||
data-active={active ? "true" : "false"}
|
||||
aria-label="MCP servers"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={McpServerIcon}
|
||||
className="size-[15px]"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* Icon doubles as an off switch: hover swaps to an X; clicking
|
||||
it turns MCP off without opening the menu. */}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Turn off MCP"
|
||||
tabIndex={-1}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMcpEnabledForChat(false);
|
||||
}}
|
||||
className="composer-pill-glyph cursor-pointer"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={McpServerIcon}
|
||||
className="size-[15px]"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<XIcon className="composer-pill-x" />
|
||||
</span>
|
||||
<span>MCP</span>
|
||||
<ArrowDownStandardIcon className="composer-pill-caret size-[15px]" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
} from "../api/prompts-api";
|
||||
import {
|
||||
listStoredChatMessages,
|
||||
listStoredChatThreads,
|
||||
saveStoredChatThread,
|
||||
syncStoredChatMessages,
|
||||
} from "../utils/chat-history-storage";
|
||||
|
|
@ -493,6 +494,36 @@ export async function exportBulkConversationsSeparate(
|
|||
);
|
||||
}
|
||||
|
||||
// Scope-level bulk export shared by the sidebar Recents menu and
|
||||
// Settings -> Chat -> Data. "recents" = chats outside projects; "all" adds
|
||||
// project chats.
|
||||
export async function bulkExportConversationsByScope(
|
||||
scope: "recents" | "all",
|
||||
format: ConvExportFormat,
|
||||
merged: boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const threads = await listStoredChatThreads({
|
||||
includeArchived: false,
|
||||
...(scope === "recents" ? { projectId: null } : {}),
|
||||
});
|
||||
const ids = [...new Set(threads.map((t) => t.id))];
|
||||
if (ids.length === 0) {
|
||||
toast.info("No conversations to export.");
|
||||
return;
|
||||
}
|
||||
const ts = new Date().toISOString().slice(0, 10);
|
||||
const basename = scope === "all" ? `all-chats-${ts}` : `recents-${ts}`;
|
||||
if (merged) {
|
||||
await exportBulkConversationsMerged(ids, format, basename);
|
||||
} else {
|
||||
await exportBulkConversationsSeparate(ids, format, basename);
|
||||
}
|
||||
} catch {
|
||||
toast.error("Export failed.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportProjectConversations(
|
||||
threadIds: string[],
|
||||
format: ConvExportFormat,
|
||||
|
|
|
|||
|
|
@ -13,15 +13,32 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
EXPORT_FORMATS_LIST,
|
||||
bulkExportConversationsByScope,
|
||||
clearAllChats,
|
||||
countAllChats,
|
||||
downloadChatExport,
|
||||
importConversationsFromFile,
|
||||
useChatRuntimeStore,
|
||||
} from "@/features/chat";
|
||||
import { useT } from "@/i18n";
|
||||
import { Delete02Icon, Download01Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Delete02Icon,
|
||||
Download01Icon,
|
||||
Upload01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SettingsRow } from "../components/settings-row";
|
||||
import { SettingsSection } from "../components/settings-section";
|
||||
|
||||
|
|
@ -61,6 +78,25 @@ export function ChatTab() {
|
|||
}
|
||||
};
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement>(null);
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
const imported = await importConversationsFromFile(file, null);
|
||||
if (imported === 0) {
|
||||
toast.info(t("settings.chat.importNoConversations"));
|
||||
} else {
|
||||
toast.success(
|
||||
imported === 1
|
||||
? t("settings.chat.importedOneChat")
|
||||
: t("settings.chat.importedChatCount", { count: imported }),
|
||||
);
|
||||
setCount(await countAllChats().catch(() => count));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("settings.chat.importFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setClearing(true);
|
||||
try {
|
||||
|
|
@ -172,6 +208,89 @@ export function ChatTab() {
|
|||
</Button>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={t("settings.chat.exportConversations")}
|
||||
description={t("settings.chat.exportConversationsDescription")}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild={true}>
|
||||
<Button variant="outline" size="sm" disabled={count === 0}>
|
||||
<HugeiconsIcon
|
||||
icon={Download01Icon}
|
||||
className="size-3.5 mr-1.5"
|
||||
/>
|
||||
{t("settings.chat.exportConversationsAction")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{(
|
||||
[
|
||||
{ scope: "recents", label: "exportScopeRecents" },
|
||||
{ scope: "all", label: "exportScopeAll" },
|
||||
] as const
|
||||
).map(({ scope, label }) => (
|
||||
<DropdownMenuSub key={scope}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<HugeiconsIcon
|
||||
icon={Download01Icon}
|
||||
className="size-3.5 mr-1"
|
||||
/>
|
||||
{t(`settings.chat.${label}`)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label: fmtLabel }) => (
|
||||
<DropdownMenuItem
|
||||
key={`${scope}-m-${fmt}`}
|
||||
onSelect={() =>
|
||||
void bulkExportConversationsByScope(scope, fmt, true)
|
||||
}
|
||||
>
|
||||
{fmtLabel} {t("settings.chat.exportCombinedSuffix")}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{EXPORT_FORMATS_LIST.map(({ fmt, label: fmtLabel }) => (
|
||||
<DropdownMenuItem
|
||||
key={`${scope}-s-${fmt}`}
|
||||
onSelect={() =>
|
||||
void bulkExportConversationsByScope(scope, fmt, false)
|
||||
}
|
||||
>
|
||||
{fmtLabel} {t("settings.chat.exportPerChatSuffix")}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
label={t("settings.chat.importChats")}
|
||||
description={t("settings.chat.importChatsDescription")}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
>
|
||||
<HugeiconsIcon icon={Upload01Icon} className="size-3.5 mr-1.5" />
|
||||
{t("settings.chat.importChatsAction")}
|
||||
</Button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".jsonl,.ndjson,.csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (file) void handleImport(file);
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
destructive
|
||||
label={t("settings.chat.clearAllChats")}
|
||||
|
|
|
|||
|
|
@ -198,6 +198,22 @@ export const en = {
|
|||
"Download all chats and messages as a JSON file.",
|
||||
exportAction: "Export",
|
||||
exportingAction: "Exporting...",
|
||||
exportConversations: "Export Recents and Projects",
|
||||
exportConversationsDescription:
|
||||
"Download Recents only, or Recents plus project chats, as Raw JSONL, CSV, or ShareGPT JSONL, combined or one file per chat.",
|
||||
exportConversationsAction: "Export",
|
||||
exportScopeRecents: "Recents",
|
||||
exportScopeAll: "Recents + Projects",
|
||||
exportCombinedSuffix: "(combined)",
|
||||
exportPerChatSuffix: "(per chat)",
|
||||
importChats: "Import chats",
|
||||
importChatsDescription:
|
||||
"Add conversations from a JSONL, NDJSON, or CSV export to Recents.",
|
||||
importChatsAction: "Import",
|
||||
importNoConversations: "No conversations found in file.",
|
||||
importedOneChat: "Imported 1 conversation to Recents.",
|
||||
importedChatCount: "Imported {count} conversations to Recents.",
|
||||
importFailed: "Import failed.",
|
||||
clearHistory: "Clear chat history",
|
||||
clearHistoryDescription: "Delete local chat history from this device.",
|
||||
clearAction: "Clear",
|
||||
|
|
|
|||
|
|
@ -174,6 +174,22 @@ export const zhCN = {
|
|||
exportHistoryDescription: "将所有聊天和消息下载为 JSON 文件。",
|
||||
exportAction: "导出",
|
||||
exportingAction: "导出中...",
|
||||
exportConversations: "导出最近对话与项目",
|
||||
exportConversationsDescription:
|
||||
"将最近对话(或加上项目内对话)导出为 Raw JSONL、CSV 或 ShareGPT JSONL,可合并为单个文件或每个对话一个文件。",
|
||||
exportConversationsAction: "导出",
|
||||
exportScopeRecents: "最近对话",
|
||||
exportScopeAll: "最近对话 + 项目",
|
||||
exportCombinedSuffix: "(合并)",
|
||||
exportPerChatSuffix: "(每个对话)",
|
||||
importChats: "导入对话",
|
||||
importChatsDescription:
|
||||
"从 JSONL、NDJSON 或 CSV 导出文件将对话导入到最近对话。",
|
||||
importChatsAction: "导入",
|
||||
importNoConversations: "文件中未找到对话。",
|
||||
importedOneChat: "已导入 1 个对话到最近对话。",
|
||||
importedChatCount: "已导入 {count} 个对话到最近对话。",
|
||||
importFailed: "导入失败。",
|
||||
clearHistory: "清除聊天记录",
|
||||
clearHistoryDescription: "从此设备删除本地聊天记录。",
|
||||
clearAction: "清除",
|
||||
|
|
|
|||
|
|
@ -1077,6 +1077,20 @@
|
|||
@apply opacity-100;
|
||||
}
|
||||
|
||||
/* Pills whose icon is an off switch (RAG, MCP) only render while the
|
||||
feature is on, so reveal the X on hover even when the pill is not in
|
||||
its active look (MCP with no servers enabled, RAG without a model). */
|
||||
.composer-pill-btn:has(> .composer-pill-glyph[role="button"]):hover
|
||||
.composer-pill-glyph
|
||||
> :not(.composer-pill-x) {
|
||||
@apply opacity-0;
|
||||
}
|
||||
|
||||
.composer-pill-btn:has(> .composer-pill-glyph[role="button"]):hover
|
||||
.composer-pill-x {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.composer-input {
|
||||
@apply mt-2 mb-1 mx-3 min-h-12 w-[calc(100%-1.5rem)] resize-none overflow-y-auto bg-transparent pl-2 pr-4 pt-2 pb-3 text-sm font-[450] outline-none placeholder:text-muted-foreground focus-visible:ring-0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue