feat: add DeepChat deeplink support (#4668)

This commit is contained in:
yyhhyyyyyy
2026-05-08 18:13:20 +08:00
committed by GitHub
parent 948780e3fa
commit 560ba57c88
9 changed files with 144 additions and 124 deletions
@@ -1,4 +1,4 @@
import { useMemo, useCallback } from 'react'
import { useMemo, useCallback, useRef, useState } from 'react'
import { Link, useLocation } from '@tanstack/react-router'
import { ExternalLink, Loader2, ChevronRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -12,7 +12,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
@@ -23,31 +22,30 @@ import {
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/sidebar'
import { useActiveChatKey } from '@/features/chat/hooks/use-active-chat-key'
import { fetchActiveChatKey } from '@/features/chat/hooks/use-active-chat-key'
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
import { resolveChatUrl, type ChatPreset } from '@/features/chat/lib/chat-links'
import {
chatLinkRequiresApiKey,
resolveChatUrl,
type ChatPreset,
} from '@/features/chat/lib/chat-links'
import { normalizeHref } from '../lib/url-utils'
import type { NavChatPresets } from '../types'
/**
* Check if a preset requires an API key
*/
function requiresApiKey(preset: ChatPreset): boolean {
return preset.url.includes('{key}') || preset.url.includes('{cherryConfig}')
}
/**
* Sub-menu item for a single chat preset
*/
function ChatMenuItem({
preset,
active,
loading,
onOpen,
onNavigate,
}: {
preset: ChatPreset
active: boolean
onOpen: (preset: ChatPreset) => void
loading: boolean
onOpen: (preset: ChatPreset) => void | Promise<void>
onNavigate: () => void
}) {
if (preset.type === 'web') {
@@ -72,12 +70,19 @@ function ChatMenuItem({
return (
<SidebarMenuSubItem>
<SidebarMenuSubButton
onClick={() => onOpen(preset)}
onClick={() => {
if (!loading) void onOpen(preset)
}}
aria-disabled={loading ? 'true' : undefined}
isActive={false}
className='justify-between'
>
<span>{preset.name}</span>
<ExternalLink className='h-4 w-4' />
{loading ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<ExternalLink className='h-4 w-4' />
)}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)
@@ -88,10 +93,12 @@ function ChatMenuItem({
*/
function DropdownPresetItem({
preset,
loading,
onOpen,
}: {
preset: ChatPreset
onOpen: (preset: ChatPreset) => void
loading: boolean
onOpen: (preset: ChatPreset) => void | Promise<void>
}) {
if (preset.type === 'web') {
return (
@@ -104,9 +111,18 @@ function DropdownPresetItem({
}
return (
<DropdownMenuItem onClick={() => onOpen(preset)}>
<DropdownMenuItem
disabled={loading}
onClick={() => {
if (!loading) void onOpen(preset)
}}
>
{preset.name}
<ExternalLink className='ml-auto h-4 w-4 opacity-70' />
{loading ? (
<Loader2 className='ml-auto h-4 w-4 animate-spin opacity-70' />
) : (
<ExternalLink className='ml-auto h-4 w-4 opacity-70' />
)}
</DropdownMenuItem>
)
}
@@ -119,44 +135,44 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
const { chatPresets, serverAddress } = useChatPresets()
const { state, isMobile, setOpenMobile } = useSidebar()
const href = useLocation({ select: (location) => location.href })
const loadingMessage = t('Preparing chat keys…')
const [loadingPresetId, setLoadingPresetId] = useState<string | null>(null)
const loadingPresetIdRef = useRef<string | null>(null)
const visiblePresets = useMemo(
() => chatPresets.filter((preset) => preset.type !== 'fluent'),
[chatPresets]
)
const hasKeyDependentPresets = useMemo(
() => visiblePresets.some(requiresApiKey),
[visiblePresets]
)
const {
data: activeKey,
isPending: isKeyPending,
error: keyError,
} = useActiveChatKey(hasKeyDependentPresets)
const handleOpenExternal = useCallback(
(preset: ChatPreset) => {
async (preset: ChatPreset) => {
if (preset.type === 'web') return
const needsKey = requiresApiKey(preset)
const needsKey = chatLinkRequiresApiKey(preset.url)
let activeKey: string | undefined
if (needsKey && isKeyPending) {
if (needsKey && loadingPresetIdRef.current) {
toast.info(t('Preparing your chat link, please try again in a moment.'))
return
}
if (needsKey && !activeKey) {
const message =
keyError instanceof Error
? keyError.message
: t(
'Unable to prepare chat link. Please ensure you have an enabled API key.'
)
toast.error(message)
return
if (needsKey) {
loadingPresetIdRef.current = preset.id
setLoadingPresetId(preset.id)
try {
activeKey = await fetchActiveChatKey()
} catch (error) {
const message =
error instanceof Error
? error.message
: t(
'Unable to prepare chat link. Please ensure you have an enabled API key.'
)
toast.error(message)
return
} finally {
loadingPresetIdRef.current = null
setLoadingPresetId(null)
}
}
const url = resolveChatUrl({
@@ -175,7 +191,7 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
window.open(url, '_blank', 'noopener')
setOpenMobile(false)
},
[activeKey, isKeyPending, keyError, serverAddress, setOpenMobile, t]
[serverAddress, setOpenMobile, t]
)
const normalizedHref = normalizeHref(href)
@@ -202,16 +218,10 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
<DropdownPresetItem
key={preset.id}
preset={preset}
loading={loadingPresetId === preset.id}
onOpen={handleOpenExternal}
/>
))}
{hasKeyDependentPresets && <DropdownMenuSeparator />}
{hasKeyDependentPresets && isKeyPending && (
<DropdownMenuItem disabled>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{loadingMessage}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
@@ -240,18 +250,11 @@ export function ChatPresetsItem({ item }: { item: NavChatPresets }) {
key={preset.id}
preset={preset}
active={normalizedHref === `/chat/${preset.id}`}
loading={loadingPresetId === preset.id}
onOpen={handleOpenExternal}
onNavigate={() => setOpenMobile(false)}
/>
))}
{hasKeyDependentPresets && isKeyPending && (
<SidebarMenuSubItem>
<SidebarMenuSubButton aria-disabled='true' tabIndex={-1}>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{loadingMessage}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
)}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>