Spaces:
Sleeping
Sleeping
Commit
·
55947a0
1
Parent(s):
2517ed6
feat: add CopyButton component and integrate copy functionality into messages
Browse files- Introduced a new CopyButton component for copying text to the clipboard.
- Implemented a custom hook, useCopy, to manage clipboard interactions and state.
- Integrated the CopyButton into the message component, allowing users to copy assistant messages easily.
- Enhanced message display logic to conditionally show the copy button based on message role and status.
- app/api/chat/route.ts +1 -1
- components/copy-button.tsx +38 -0
- components/message.tsx +18 -0
- lib/hooks/use-copy.ts +31 -0
app/api/chat/route.ts
CHANGED
|
@@ -56,7 +56,7 @@ export async function POST(req: Request) {
|
|
| 56 |
|
| 57 |
// Check if chat already exists for the given ID
|
| 58 |
// If not, we'll create it in onFinish
|
| 59 |
-
|
| 60 |
if (chatId) {
|
| 61 |
try {
|
| 62 |
const existingChat = await db.query.chats.findFirst({
|
|
|
|
| 56 |
|
| 57 |
// Check if chat already exists for the given ID
|
| 58 |
// If not, we'll create it in onFinish
|
| 59 |
+
let isNewChat = false;
|
| 60 |
if (chatId) {
|
| 61 |
try {
|
| 62 |
const existingChat = await db.query.chats.findFirst({
|
components/copy-button.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
| 2 |
+
import { cn } from "@/lib/utils";
|
| 3 |
+
import { useCopy } from "@/lib/hooks/use-copy";
|
| 4 |
+
import { Button } from "./ui/button";
|
| 5 |
+
|
| 6 |
+
interface CopyButtonProps {
|
| 7 |
+
text: string;
|
| 8 |
+
className?: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function CopyButton({ text, className }: CopyButtonProps) {
|
| 12 |
+
const { copied, copy } = useCopy();
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<Button
|
| 16 |
+
variant="ghost"
|
| 17 |
+
size="sm"
|
| 18 |
+
className={cn(
|
| 19 |
+
"transition-opacity opacity-0 group-hover/message:opacity-100 gap-1.5",
|
| 20 |
+
className
|
| 21 |
+
)}
|
| 22 |
+
onClick={() => copy(text)}
|
| 23 |
+
title="Copy to clipboard"
|
| 24 |
+
>
|
| 25 |
+
{copied ? (
|
| 26 |
+
<>
|
| 27 |
+
<CheckIcon className="h-4 w-4" />
|
| 28 |
+
<span className="text-xs">Copied!</span>
|
| 29 |
+
</>
|
| 30 |
+
) : (
|
| 31 |
+
<>
|
| 32 |
+
<CopyIcon className="h-4 w-4" />
|
| 33 |
+
<span className="text-xs">Copy</span>
|
| 34 |
+
</>
|
| 35 |
+
)}
|
| 36 |
+
</Button>
|
| 37 |
+
);
|
| 38 |
+
}
|
components/message.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
|
|
| 9 |
import { ChevronDownIcon, ChevronUpIcon, LightbulbIcon } from "lucide-react";
|
| 10 |
import { SpinnerIcon } from "./icons";
|
| 11 |
import { ToolInvocation } from "./tool-invocation";
|
|
|
|
| 12 |
|
| 13 |
interface ReasoningPart {
|
| 14 |
type: "reasoning";
|
|
@@ -102,6 +103,18 @@ const PurePreviewMessage = ({
|
|
| 102 |
status: "error" | "submitted" | "streaming" | "ready";
|
| 103 |
isLatestMessage: boolean;
|
| 104 |
}) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
return (
|
| 106 |
<AnimatePresence key={message.id}>
|
| 107 |
<motion.div
|
|
@@ -174,6 +187,11 @@ const PurePreviewMessage = ({
|
|
| 174 |
return null;
|
| 175 |
}
|
| 176 |
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</div>
|
| 178 |
</div>
|
| 179 |
</motion.div>
|
|
|
|
| 9 |
import { ChevronDownIcon, ChevronUpIcon, LightbulbIcon } from "lucide-react";
|
| 10 |
import { SpinnerIcon } from "./icons";
|
| 11 |
import { ToolInvocation } from "./tool-invocation";
|
| 12 |
+
import { CopyButton } from "./copy-button";
|
| 13 |
|
| 14 |
interface ReasoningPart {
|
| 15 |
type: "reasoning";
|
|
|
|
| 103 |
status: "error" | "submitted" | "streaming" | "ready";
|
| 104 |
isLatestMessage: boolean;
|
| 105 |
}) => {
|
| 106 |
+
// Create a string with all text parts for copy functionality
|
| 107 |
+
const getMessageText = () => {
|
| 108 |
+
if (!message.parts) return "";
|
| 109 |
+
return message.parts
|
| 110 |
+
.filter(part => part.type === "text")
|
| 111 |
+
.map(part => (part.type === "text" ? part.text : ""))
|
| 112 |
+
.join("\n\n");
|
| 113 |
+
};
|
| 114 |
+
|
| 115 |
+
// Only show copy button if the message is from the assistant and not currently streaming
|
| 116 |
+
const shouldShowCopyButton = message.role === "assistant" && (!isLatestMessage || status !== "streaming");
|
| 117 |
+
|
| 118 |
return (
|
| 119 |
<AnimatePresence key={message.id}>
|
| 120 |
<motion.div
|
|
|
|
| 187 |
return null;
|
| 188 |
}
|
| 189 |
})}
|
| 190 |
+
{shouldShowCopyButton && (
|
| 191 |
+
<div className="flex justify-start mt-2">
|
| 192 |
+
<CopyButton text={getMessageText()} />
|
| 193 |
+
</div>
|
| 194 |
+
)}
|
| 195 |
</div>
|
| 196 |
</div>
|
| 197 |
</motion.div>
|
lib/hooks/use-copy.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from 'react';
|
| 2 |
+
|
| 3 |
+
export function useCopy(timeout = 2000) {
|
| 4 |
+
const [copied, setCopied] = useState(false);
|
| 5 |
+
|
| 6 |
+
const copy = useCallback(
|
| 7 |
+
async (text: string) => {
|
| 8 |
+
if (!navigator.clipboard) {
|
| 9 |
+
console.error('Clipboard API not available');
|
| 10 |
+
return false;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
try {
|
| 14 |
+
await navigator.clipboard.writeText(text);
|
| 15 |
+
setCopied(true);
|
| 16 |
+
|
| 17 |
+
setTimeout(() => {
|
| 18 |
+
setCopied(false);
|
| 19 |
+
}, timeout);
|
| 20 |
+
|
| 21 |
+
return true;
|
| 22 |
+
} catch (error) {
|
| 23 |
+
console.error('Failed to copy text:', error);
|
| 24 |
+
return false;
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
[timeout]
|
| 28 |
+
);
|
| 29 |
+
|
| 30 |
+
return { copied, copy };
|
| 31 |
+
}
|