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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 144 additions and 124 deletions

View File

@ -22,6 +22,9 @@ var Chats = []map[string]string{
{
"CC Switch": "ccswitch",
},
{
"DeepChat": "deepchat://provider/install?v=1&data={deepchatConfig}",
},
{
"Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}",
},

View File

@ -251,7 +251,11 @@ const SiderBar = ({ onNavigate = () => {} }) => {
for (let key in chats[i]) {
let link = chats[i][key];
if (typeof link !== 'string') continue; //
if (link.startsWith('fluent') || link.startsWith('ccswitch')) {
if (
link.startsWith('fluent') ||
link.startsWith('ccswitch') ||
link.startsWith('deepchat')
) {
shouldSkip = true;
break;
}

View File

@ -251,6 +251,16 @@ export const useTokensData = (openFluentNotification, openCCSwitchModal) => {
encodeToBase64(JSON.stringify(aionuiConfig)),
);
url = url.replaceAll('{aionuiConfig}', encodedConfig);
} else if (url.includes('{deepchatConfig}') === true) {
let deepchatConfig = {
id: 'new-api',
baseUrl: serverAddress,
apiKey: `sk-${fullKey}`,
};
let encodedConfig = encodeURIComponent(
encodeToBase64(JSON.stringify(deepchatConfig)),
);
url = url.replaceAll('{deepchatConfig}', encodedConfig);
} else {
let encodedServerAddress = encodeURIComponent(serverAddress);
url = url.replaceAll('{address}', encodedServerAddress);

View File

@ -71,6 +71,7 @@ export default function SettingsChats(props) {
{ name: 'AionUI', url: 'aionui://provider/add?v=1&data={aionuiConfig}' },
{ name: '流畅阅读', url: 'fluentread' },
{ name: 'CC Switch', url: 'ccswitch' },
{ name: 'DeepChat', url: 'deepchat://provider/install?v=1&data={deepchatConfig}' },
{ name: 'Lobe Chat', url: 'https://chat-preview.lobehub.com/?settings={"keyVaults":{"openai":{"apiKey":"{key}","baseURL":"{address}/v1"}}}' },
{ name: 'AI as Workspace', url: 'https://aiaw.app/set-provider?provider={"type":"openai","settings":{"apiKey":"{key}","baseURL":"{address}/v1","compatibility":"strict"}}' },
{ name: 'AMA 问天', url: 'ama://set-api-key?server={address}&key={key}' },

View File

@ -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>

View File

@ -1,30 +1,38 @@
import { useQuery } from '@tanstack/react-query'
import { getApiKeys } from '@/features/keys/api'
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
import { API_KEY_STATUS } from '@/features/keys/constants'
import { useAuthStore } from '@/stores/auth-store'
export async function fetchActiveChatKey() {
const result = await getApiKeys({ p: 1, size: 50 })
if (!result.success) {
throw new Error(result.message || 'Failed to load API keys')
}
const items = result.data?.items ?? []
const active = items.find((item) => item.status === API_KEY_STATUS.ENABLED)
if (!active) {
throw new Error('No enabled API keys found. Create or enable one first.')
}
const keyResult = await fetchTokenKey(active.id)
if (!keyResult.success || !keyResult.data?.key) {
throw new Error(keyResult.message || 'Failed to load API key')
}
return `sk-${keyResult.data.key}`
}
/**
* Get the currently active API key for chat links
*/
export function useActiveChatKey(enabled: boolean) {
const userId = useAuthStore((state) => state.auth.user?.id)
return useQuery({
queryKey: ['chat-active-key'],
queryFn: async () => {
const result = await getApiKeys({ p: 1, size: 50 })
if (!result.success) {
throw new Error(result.message || 'Failed to load API keys')
}
const items = result.data?.items ?? []
const active = items.find(
(item) => item.status === API_KEY_STATUS.ENABLED
)
if (!active) {
throw new Error(
'No enabled API keys found. Create or enable one first.'
)
}
return active.key
},
enabled,
queryKey: ['chat-active-key', userId],
queryFn: fetchActiveChatKey,
enabled: enabled && Boolean(userId),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})

View File

@ -66,6 +66,15 @@ export function detectChatLinkType(url: string): ChatLinkType {
return 'custom-protocol'
}
export function chatLinkRequiresApiKey(url: string): boolean {
return (
url.includes('{key}') ||
url.includes('{cherryConfig}') ||
url.includes('{aionuiConfig}') ||
url.includes('{deepchatConfig}')
)
}
export function parseChatConfig(raw: RawChatConfig): ChatPreset[] {
let parsed: unknown = raw
@ -146,6 +155,16 @@ export function resolveChatUrl({
return replaceToken(url, '{aionuiConfig}', encoded)
}
if (url.includes('{deepchatConfig}')) {
const payload = {
id: 'new-api',
baseUrl: safeServerAddress,
apiKey: safeApiKey,
}
const encoded = encodeURIComponent(toBase64(JSON.stringify(payload)))
return replaceToken(url, '{deepchatConfig}', encoded)
}
if (safeServerAddress) {
const encodedAddress = encodeURIComponent(safeServerAddress)
url = replaceToken(url, '{address}', encodedAddress)

View File

@ -1,14 +1,15 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link, createFileRoute, redirect } from '@tanstack/react-router'
import { Loader2, MessageCircleWarning } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { useActiveChatKey } from '@/features/chat/hooks/use-active-chat-key'
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
import { resolveChatUrl } from '@/features/chat/lib/chat-links'
import { getApiKeys } from '@/features/keys/api'
import { API_KEY_STATUS } from '@/features/keys/constants'
import {
chatLinkRequiresApiKey,
resolveChatUrl,
} from '@/features/chat/lib/chat-links'
export const Route = createFileRoute('/_authenticated/chat/$chatId')({
loader: async ({ params }) => {
@ -33,8 +34,7 @@ function ChatRouteComponent() {
const requiresActiveKey = useMemo(() => {
if (!preset || !isWebLink) return false
const url = preset.url ?? ''
return url.includes('{key}') || url.includes('{cherryConfig}')
return chatLinkRequiresApiKey(preset.url ?? '')
}, [isWebLink, preset])
const {
@ -42,28 +42,7 @@ function ChatRouteComponent() {
isPending,
isError,
error,
} = useQuery({
queryKey: ['chat-active-key'],
queryFn: async () => {
const result = await getApiKeys({ p: 1, size: 50 })
if (!result.success) {
throw new Error(result.message || 'Failed to load API keys')
}
const items = result.data?.items ?? []
const active = items.find(
(item) => item.status === API_KEY_STATUS.ENABLED
)
if (!active) {
throw new Error(
'No enabled API key available. Please enable an API key first.'
)
}
return active.key
},
enabled: Boolean(preset && requiresActiveKey),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
})
} = useActiveChatKey(Boolean(preset && requiresActiveKey))
const iframeSrc = useMemo(() => {
if (!preset || !isWebLink) return ''

View File

@ -1,13 +1,11 @@
import { useEffect, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useActiveChatKey } from '@/features/chat/hooks/use-active-chat-key'
import { useChatPresets } from '@/features/chat/hooks/use-chat-presets'
import { resolveChatUrl } from '@/features/chat/lib/chat-links'
import { getApiKeys } from '@/features/keys/api'
import { API_KEY_STATUS } from '@/features/keys/constants'
export const Route = createFileRoute('/_authenticated/chat2link')({
component: Chat2LinkPage,
@ -23,19 +21,9 @@ function Chat2LinkPage() {
[chatPresets]
)
const { data: activeKey } = useQuery({
queryKey: ['chat2link-active-key'],
queryFn: async () => {
const result = await getApiKeys({ p: 1, size: 50 })
if (!result.success) throw new Error(result.message)
const items = result.data?.items ?? []
const active = items.find(
(item) => item.status === API_KEY_STATUS.ENABLED
)
return active?.key ?? null
},
staleTime: 5 * 60 * 1000,
})
const { data: activeKey, error: keyError } = useActiveChatKey(
Boolean(firstWebPreset)
)
useEffect(() => {
if (!firstWebPreset) {
@ -45,10 +33,14 @@ function Chat2LinkPage() {
return
}
if (activeKey === undefined) return
if (activeKey === undefined && !keyError) return
if (!activeKey) {
toast.error(t('No enabled tokens available'))
if (keyError || !activeKey) {
const message =
keyError instanceof Error
? keyError.message
: t('No enabled tokens available')
toast.error(message)
navigate({ to: '/keys' })
return
}
@ -65,6 +57,7 @@ function Chat2LinkPage() {
}, [
firstWebPreset,
activeKey,
keyError,
serverAddress,
chatPresets.length,
navigate,