Skip to content

Commit

Permalink
Merge pull request #132 from miurla/share
Browse files Browse the repository at this point in the history
Enable sharing of search results
  • Loading branch information
miurla authored May 11, 2024
2 parents 60fd39a + b11e180 commit a19f9fc
Show file tree
Hide file tree
Showing 11 changed files with 225 additions and 14 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Please note that there are differences between this repository and the official

- [x] Enable specifying the model to use (only writer agent)
- [x] Implement search history functionality
- [ ] Develop features for sharing results
- [x] Develop features for sharing results
- [ ] Add video support for search functionality
- [ ] Implement RAG support
- [ ] Introduce tool support for enhanced productivity
Expand Down
21 changes: 18 additions & 3 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ async function submit(formData?: FormData, skip?: boolean) {
export type AIState = {
messages: AIMessage[]
chatId: string
isSharePage?: boolean
}

export type UIState = {
Expand Down Expand Up @@ -304,11 +305,19 @@ export const AI = createAI<AIState, UIState>({
})

export const getUIStateFromAIState = (aiState: Chat) => {
const chatId = aiState.chatId
const isSharePage = aiState.isSharePage
return aiState.messages
.map(message => {
.map((message, index) => {
const { role, content, id, type, name } = message

if (!type || type === 'end') return null
if (
!type ||
type === 'end' ||
(isSharePage && type === 'related') ||
(isSharePage && type === 'followup')
)
return null

switch (role) {
case 'user':
Expand All @@ -319,7 +328,13 @@ export const getUIStateFromAIState = (aiState: Chat) => {
const value = type === 'input' ? json.input : json.related_query
return {
id,
component: <UserMessage message={value} />
component: (
<UserMessage
message={value}
chatId={chatId}
showShare={index === 0 && !isSharePage}
/>
)
}
case 'inquiry':
return {
Expand Down
7 changes: 6 additions & 1 deletion app/search/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ export default async function SearchPage({ params }: SearchPageProps) {
}

return (
<AI initialAIState={{ chatId: chat.id, messages: chat.messages }}>
<AI
initialAIState={{
chatId: chat.id,
messages: chat.messages
}}
>
<Chat id={params.id} />
</AI>
)
Expand Down
42 changes: 42 additions & 0 deletions app/share/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { notFound } from 'next/navigation'
import { Chat } from '@/components/chat'
import { getSharedChat } from '@/lib/actions/chat'
import { AI } from '@/app/actions'

export interface SharePageProps {
params: {
id: string
}
}

export async function generateMetadata({ params }: SharePageProps) {
const chat = await getSharedChat(params.id)

if (!chat || !chat.sharePath) {
return notFound()
}

return {
title: chat?.title.toString().slice(0, 50) || 'Search'
}
}

export default async function SharePage({ params }: SharePageProps) {
const chat = await getSharedChat(params.id)

if (!chat || !chat.sharePath) {
notFound()
}

return (
<AI
initialAIState={{
chatId: chat.id,
messages: chat.messages,
isSharePage: true
}}
>
<Chat id={params.id} />
</AI>
)
}
83 changes: 83 additions & 0 deletions components/chat-share.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'use client'

import { useState, useTransition } from 'react'
import { Button } from './ui/button'
import { Share } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
DialogDescription,
DialogTitle
} from './ui/dialog'
import { shareChat } from '@/lib/actions/chat'
import { toast } from 'sonner'
import { useCopyToClipboard } from '@/lib/hooks/use-copy-to-clipboard'
import { Spinner } from './ui/spinner'

interface ChatShareProps {
chatId: string
className?: string
}

export function ChatShare({ chatId, className }: ChatShareProps) {
const [open, setOpen] = useState(false)
const [pending, startTransition] = useTransition()
const { copyToClipboard } = useCopyToClipboard({ timeout: 1000 })

const handleClick = async () => {
startTransition(async () => {
const result = await shareChat(chatId)
if (!result) {
toast.error('Failed to share chat')
}

if (!result?.sharePath) {
toast.error('Could not copy link to clipboard')
return
}

const url = new URL(result.sharePath, window.location.origin)
copyToClipboard(url.toString())
toast.success('Link copied to clipboard')
setOpen(false)
})
}

return (
<div className={className}>
<Dialog
open={open}
onOpenChange={open => setOpen(open)}
aria-labelledby="share-dialog-title"
aria-describedby="share-dialog-description"
>
<DialogTrigger asChild>
<Button
className="rounded-full"
size="icon"
variant={'ghost'}
onClick={() => setOpen(true)}
>
<Share size={14} />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Share link to search result</DialogTitle>
<DialogDescription>
Anyone with the link will be able to view this search result.
</DialogDescription>
</DialogHeader>
<DialogFooter className="items-center">
<Button onClick={handleClick} disabled={pending} size="sm">
{pending ? <Spinner /> : 'Copy link'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
2 changes: 1 addition & 1 deletion components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export function Chat({ id }: ChatProps) {
}, [aiState, router])

return (
<div className="px-8 sm:px-12 pt-6 md:pt-8 pb-14 md:pb-24 max-w-3xl mx-auto flex flex-col space-y-3 md:space-y-4">
<div className="px-8 sm:px-12 pt-12 md:pt-14 pb-14 md:pb-24 max-w-3xl mx-auto flex flex-col space-y-3 md:space-y-4">
<ChatMessages messages={messages} />
<ChatPanel messages={messages} />
</div>
Expand Down
1 change: 1 addition & 0 deletions components/collapsible-message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export const CollapsibleMessage: React.FC<CollapsibleMessageProps> = ({
className={cn('-mt-3 rounded-full')}
>
<ChevronDown
size={14}
className={cn(
open ? 'rotate-180' : 'rotate-0',
'h-4 w-4 transition-all'
Expand Down
5 changes: 1 addition & 4 deletions components/copilot-display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ export function CopilotDisplay({ content }: CopilotDisplayProps) {

return (
<Card className="p-3 md:p-4 w-full flex justify-between items-center">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<IconLogo className="w-4 h-4 flex-shrink-0" />
<h5 className="text-muted-foreground text-xs truncate">{query}</h5>
</div>
<h5 className="text-muted-foreground text-xs truncate">{query}</h5>
<Check size={16} className="text-green-500 w-4 h-4" />
</Card>
)
Expand Down
14 changes: 11 additions & 3 deletions components/user-message.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import React from 'react'
import { ChatShare } from './chat-share'

type UserMessageProps = {
message: string
chatId?: string
showShare?: boolean
}

export const UserMessage: React.FC<UserMessageProps> = ({ message }) => {
export const UserMessage: React.FC<UserMessageProps> = ({
message,
chatId,
showShare = false
}) => {
return (
<div className="mt-6">
<div className="text-xl">{message}</div>
<div className="flex items-center w-full space-x-1 mt-2 min-h-10">
<div className="text-xl flex-1">{message}</div>
{showShare && chatId && <ChatShare chatId={chatId} />}
</div>
)
}
29 changes: 28 additions & 1 deletion lib/actions/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,31 @@ export async function saveChat(chat: Chat, userId: string = 'anonymous') {
member: `chat:${chat.id}`
})
await pipeline.exec()
}
}

export async function getSharedChat(id: string) {
const chat = await redis.hgetall<Chat>(`chat:${id}`)

if (!chat || !chat.sharePath) {
return null
}

return chat
}

export async function shareChat(id: string, userId: string = 'anonymous') {
const chat = await redis.hgetall<Chat>(`chat:${id}`)

if (!chat || chat.userId !== userId) {
return null
}

const payload = {
...chat,
sharePath: `/share/${id}`
}

await redis.hmset(`chat:${id}`, payload)

return payload
}
33 changes: 33 additions & 0 deletions lib/hooks/use-copy-to-clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client'

import { useState } from 'react'

export interface useCopyToClipboardProps {
timeout?: number
}

export function useCopyToClipboard({
timeout = 2000
}: useCopyToClipboardProps) {
const [isCopied, setIsCopied] = useState<Boolean>(false)

const copyToClipboard = (value: string) => {
if (typeof window === 'undefined' || !navigator.clipboard?.writeText) {
return
}

if (!value) {
return
}

navigator.clipboard.writeText(value).then(() => {
setIsCopied(true)

setTimeout(() => {
setIsCopied(false)
}, timeout)
})
}

return { isCopied, copyToClipboard }
}

0 comments on commit a19f9fc

Please sign in to comment.