Spaces:
Running
Running
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 |
+
}
|