feat: add DeepChat deeplink support (#4668)
This commit is contained in:
parent
948780e3fa
commit
560ba57c88
@ -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\"}}}",
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
10
web/classic/src/hooks/tokens/useTokensData.jsx
vendored
10
web/classic/src/hooks/tokens/useTokensData.jsx
vendored
@ -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);
|
||||
|
||||
@ -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}' },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
19
web/default/src/features/chat/lib/chat-links.ts
vendored
19
web/default/src/features/chat/lib/chat-links.ts
vendored
@ -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)
|
||||
|
||||
@ -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 ''
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user