Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions setting/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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\"}}}",
},
Expand Down
6 changes: 5 additions & 1 deletion web/classic/src/components/layout/SiderBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
10 changes: 10 additions & 0 deletions web/classic/src/hooks/tokens/useTokensData.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions web/classic/src/pages/Setting/Chat/SettingsChats.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}' },
Expand Down
117 changes: 60 additions & 57 deletions web/default/src/components/layout/components/chat-presets-item.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -12,7 +12,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Expand All @@ -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') {
Expand All @@ -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>
)
Expand All @@ -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 (
Expand All @@ -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>
)
}
Expand All @@ -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({
Expand All @@ -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)
Expand All @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
46 changes: 27 additions & 19 deletions web/default/src/features/chat/hooks/use-active-chat-key.ts
Original file line number Diff line number Diff line change
@@ -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.')
Comment on lines +7 to +15
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enabled-key detection is limited to page 1.

Line 7 fetches only the first 50 keys, and Line 13 only searches that slice. If the first enabled key is on a later page, Line 15 throws a false “No enabled API keys found” error.

💡 Suggested fix (scan pages until an enabled key is found)
 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)
+  const pageSize = 50
+  let page = 1
+  let active: { id: string | number } | undefined
+
+  while (!active) {
+    const result = await getApiKeys({ p: page, size: pageSize })
+    if (!result.success) {
+      throw new Error(result.message || 'Failed to load API keys')
+    }
+
+    const items = result.data?.items ?? []
+    active = items.find((item) => item.status === API_KEY_STATUS.ENABLED)
+    if (active || items.length < pageSize) break
+    page += 1
+  }
+
   if (!active) {
     throw new Error('No enabled API keys found. Create or enable one first.')
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 pageSize = 50
let page = 1
let active: { id: string | number } | undefined
while (!active) {
const result = await getApiKeys({ p: page, size: pageSize })
if (!result.success) {
throw new Error(result.message || 'Failed to load API keys')
}
const items = result.data?.items ?? []
active = items.find((item) => item.status === API_KEY_STATUS.ENABLED)
if (active || items.length < pageSize) break
page += 1
}
if (!active) {
throw new Error('No enabled API keys found. Create or enable one first.')
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/default/src/features/chat/hooks/use-active-chat-key.ts` around lines 7 -
15, The current code calls getApiKeys({ p: 1, size: 50 }) and only searches that
first page for an API_KEY_STATUS.ENABLED key, which can falsely throw if an
enabled key exists on a later page; change the logic in useActiveChatKey to
page-scan: call getApiKeys in a loop (increment p) until you find an item with
status === API_KEY_STATUS.ENABLED or until you've exhausted pages (use
result.data.total/size or stop when returned items length is 0), return the
first enabled key when found, and only throw the "No enabled API keys found"
error after all pages have been checked; keep references to getApiKeys,
API_KEY_STATUS.ENABLED, and the variables result/items/active when updating the
implementation.

}

const keyResult = await fetchTokenKey(active.id)
if (!keyResult.success || !keyResult.data?.key) {
throw new Error(keyResult.message || 'Failed to load API key')
Comment on lines +9 to +20
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify there are no remaining hardcoded string literals in throw-new-Error calls in this hook.
rg -n "throw new Error\\(['\"][^'\"]+['\"]\\)" web/default/src/features/chat/hooks/use-active-chat-key.ts

Repository: QuantumNous/new-api

Length of output: 144


🏁 Script executed:

cat -n web/default/src/features/chat/hooks/use-active-chat-key.ts

Repository: QuantumNous/new-api

Length of output: 1564


🏁 Script executed:

# Verify the file follows the expected pattern for non-React utility functions
head -5 web/default/src/features/chat/hooks/use-active-chat-key.ts | grep -E "(React|useTranslation|i18next)"

Repository: QuantumNous/new-api

Length of output: 45


Use t() from i18next for user-visible error messages in this utility function.

Lines 9, 15, and 20 contain hardcoded error strings that violate the i18n requirement for .ts files. Replace with t() calls:

Suggested refactor
+import { t } from 'i18next'
 import { useQuery } from '@tanstack/react-query'
 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')
+    throw new Error(result.message || t('chat.errors.failedToLoadApiKeys'))
   }

   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.')
+    throw new Error(t('chat.errors.noEnabledApiKeys'))
   }

   const keyResult = await fetchTokenKey(active.id)
   if (!keyResult.success || !keyResult.data?.key) {
-    throw new Error(keyResult.message || 'Failed to load API key')
+    throw new Error(keyResult.message || t('chat.errors.failedToLoadApiKey'))
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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')
import { t } from 'i18next'
import { useQuery } from '@tanstack/react-query'
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 || t('chat.errors.failedToLoadApiKeys'))
}
const items = result.data?.items ?? []
const active = items.find((item) => item.status === API_KEY_STATUS.ENABLED)
if (!active) {
throw new Error(t('chat.errors.noEnabledApiKeys'))
}
const keyResult = await fetchTokenKey(active.id)
if (!keyResult.success || !keyResult.data?.key) {
throw new Error(keyResult.message || t('chat.errors.failedToLoadApiKey'))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/default/src/features/chat/hooks/use-active-chat-key.ts` around lines 9 -
20, Replace the three user-facing hardcoded error strings in the
use-active-chat-key flow with i18n calls: use t() from i18next for the 'Failed
to load API keys', 'No enabled API keys found. Create or enable one first.' and
'Failed to load API key' messages; update the code around the items/result
checks and the fetchTokenKey handling (references: result, items, active,
API_KEY_STATUS, fetchTokenKey) to throw new Error(t('...')) with appropriate
translation keys, and add an import for t from 'i18next' at the top if it's not
already imported.

}

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,
})
Expand Down
19 changes: 19 additions & 0 deletions web/default/src/features/chat/lib/chat-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Loading