Add Auth for WebPage and APIs

This commit is contained in:
Raymond Zhou 2025-03-09 12:19:58 +08:00
parent e6b87f89ec
commit 5e6d0e0ee6
27 changed files with 15384 additions and 1720 deletions

View file

@ -4,51 +4,5 @@ services:
volumes: volumes:
- ./searxng:/etc/searxng:rw - ./searxng:/etc/searxng:rw
ports: ports:
- 4000:8080 - 3666:8080
networks: restart: unless-stopped
- perplexica-network
restart: unless-stopped
perplexica-backend:
build:
context: .
dockerfile: backend.dockerfile
image: itzcrazykns1337/perplexica-backend:main
environment:
- SEARXNG_API_URL=http://searxng:8080
depends_on:
- searxng
ports:
- 3001:3001
volumes:
- backend-dbstore:/home/perplexica/data
- uploads:/home/perplexica/uploads
- ./config.toml:/home/perplexica/config.toml
extra_hosts:
- 'host.docker.internal:host-gateway'
networks:
- perplexica-network
restart: unless-stopped
perplexica-frontend:
build:
context: .
dockerfile: app.dockerfile
args:
- NEXT_PUBLIC_API_URL=http://127.0.0.1:3001/api
- NEXT_PUBLIC_WS_URL=ws://127.0.0.1:3001
image: itzcrazykns1337/perplexica-frontend:main
depends_on:
- perplexica-backend
ports:
- 3000:3000
networks:
- perplexica-network
restart: unless-stopped
networks:
perplexica-network:
volumes:
backend-dbstore:
uploads:

6720
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -30,8 +30,9 @@
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@langchain/anthropic": "^0.2.3", "@langchain/anthropic": "^0.2.3",
"@langchain/community": "^0.2.16", "@langchain/community": "^0.2.16",
"@langchain/openai": "^0.0.25",
"@langchain/google-genai": "^0.0.23", "@langchain/google-genai": "^0.0.23",
"@langchain/openai": "^0.0.25",
"@types/jsonwebtoken": "^9.0.9",
"@xenova/transformers": "^2.17.1", "@xenova/transformers": "^2.17.1",
"axios": "^1.6.8", "axios": "^1.6.8",
"better-sqlite3": "^11.0.0", "better-sqlite3": "^11.0.0",
@ -42,6 +43,7 @@
"drizzle-orm": "^0.31.2", "drizzle-orm": "^0.31.2",
"express": "^4.19.2", "express": "^4.19.2",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"jsonwebtoken": "^9.0.2",
"langchain": "^0.1.30", "langchain": "^0.1.30",
"mammoth": "^1.8.0", "mammoth": "^1.8.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",

View file

@ -1,27 +0,0 @@
[GENERAL]
PORT = 3001 # Port to run the server on
SIMILARITY_MEASURE = "cosine" # "cosine" or "dot"
KEEP_ALIVE = "5m" # How long to keep Ollama models loaded into memory. (Instead of using -1 use "-1m")
[MODELS.OPENAI]
API_KEY = ""
[MODELS.GROQ]
API_KEY = ""
[MODELS.ANTHROPIC]
API_KEY = ""
[MODELS.GEMINI]
API_KEY = ""
[MODELS.CUSTOM_OPENAI]
API_KEY = ""
API_URL = ""
MODEL_NAME = ""
[MODELS.OLLAMA]
API_URL = "" # Ollama API URL - http://host.docker.internal:11434
[API_ENDPOINTS]
SEARXNG = "http://localhost:32768" # SearxNG API URL

View file

@ -3,8 +3,10 @@ import express from 'express';
import cors from 'cors'; import cors from 'cors';
import http from 'http'; import http from 'http';
import routes from './routes'; import routes from './routes';
import authRouter from './routes/auth';
import { getPort } from './config'; import { getPort } from './config';
import logger from './utils/logger'; import logger from './utils/logger';
import { authMiddleware } from './middleware/auth';
const port = getPort(); const port = getPort();
@ -18,11 +20,18 @@ const corsOptions = {
app.use(cors(corsOptions)); app.use(cors(corsOptions));
app.use(express.json()); app.use(express.json());
app.use('/api', routes); // 公开路由 - 无需认证
app.get('/api', (_, res) => { app.get('/api', (_, res) => {
res.status(200).json({ status: 'ok' }); res.status(200).json({ status: 'ok' });
}); });
// 认证路由 - 无需认证
app.use('/api/auth', authRouter);
// 受保护的路由 - 需要认证
// 注意:这里必须在'/api/auth'之后定义,避免认证中间件拦截认证请求
app.use('/api', authMiddleware, routes);
server.listen(port, () => { server.listen(port, () => {
logger.info(`Server is running on port ${port}`); logger.info(`Server is running on port ${port}`);
}); });

View file

@ -35,6 +35,9 @@ interface Config {
API_ENDPOINTS: { API_ENDPOINTS: {
SEARXNG: string; SEARXNG: string;
}; };
AUTH_PAGE: {
AUTH_SECRET: string;
};
} }
type RecursivePartial<T> = { type RecursivePartial<T> = {
@ -64,6 +67,8 @@ export const getGeminiApiKey = () => loadConfig().MODELS.GEMINI.API_KEY;
export const getSearxngApiEndpoint = () => export const getSearxngApiEndpoint = () =>
process.env.SEARXNG_API_URL || loadConfig().API_ENDPOINTS.SEARXNG; process.env.SEARXNG_API_URL || loadConfig().API_ENDPOINTS.SEARXNG;
export const getAuthSecret = () => loadConfig().AUTH_PAGE.AUTH_SECRET;
export const getOllamaApiEndpoint = () => loadConfig().MODELS.OLLAMA.API_URL; export const getOllamaApiEndpoint = () => loadConfig().MODELS.OLLAMA.API_URL;
export const getCustomOpenaiApiKey = () => export const getCustomOpenaiApiKey = () =>

54
src/middleware/auth.ts Normal file
View file

@ -0,0 +1,54 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { getAuthSecret } from '../config';
import logger from '../utils/logger';
// 扩展Express的Request类型添加用户信息
declare global {
namespace Express {
interface Request {
user?: {
authenticated: boolean;
};
}
}
}
/**
* JWT令牌的中间件
*/
export const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
// 从请求头获取令牌
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN格式
// 如果没有令牌
if (!token) {
return res.status(401).json({ message: '未提供认证令牌' });
}
try {
// 验证令牌
const secret = getAuthSecret();
const decoded = jwt.verify(token, secret);
// 将用户信息添加到请求对象中
req.user = {
authenticated: true
};
next();
} catch (err: any) {
logger.error(`令牌验证失败: ${err.message}`);
return res.status(403).json({ message: '无效的认证令牌' });
}
};
/**
* JWT令牌
*/
export const generateToken = (): string => {
const secret = getAuthSecret();
// 令牌有效期为7天
return jwt.sign({ authenticated: true }, secret, { expiresIn: '7d' });
};

46
src/routes/auth.ts Normal file
View file

@ -0,0 +1,46 @@
import express from 'express';
import logger from '../utils/logger';
import { getAuthSecret } from '../config';
import { generateToken } from '../middleware/auth';
const router = express.Router();
// 预设的密码,实际应用中应该从环境变量或配置文件中获取
const AUTH_PASSWORD = getAuthSecret();
router.post('/verify', (req, res) => {
try {
const { password } = req.body;
if (!password) {
return res.status(400).json({ message: '密码不能为空' });
}
if (password === AUTH_PASSWORD) {
// 生成JWT令牌
const token = generateToken();
return res.status(200).json({
message: '验证成功',
token: token
});
} else {
return res.status(401).json({ message: '密码错误' });
}
} catch (err: any) {
logger.error(`认证错误: ${err.message}`);
return res.status(500).json({ message: '服务器错误' });
}
});
// 验证令牌是否有效的端点
router.get('/verify-token', (req, res) => {
try {
// 令牌验证已经在中间件中完成,如果能到达这里,说明令牌有效
return res.status(200).json({ valid: true });
} catch (err: any) {
logger.error(`令牌验证错误: ${err.message}`);
return res.status(500).json({ message: '服务器错误' });
}
});
export default router;

View file

@ -1,14 +1,51 @@
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { handleConnection } from './connectionManager'; import { handleConnection } from './connectionManager';
import http from 'http'; import http from 'http';
import { getPort } from '../config'; import { getPort, getAuthSecret } from '../config';
import logger from '../utils/logger'; import logger from '../utils/logger';
import jwt from 'jsonwebtoken';
import { URL } from 'url';
export const initServer = ( export const initServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => { ) => {
const port = getPort(); const port = getPort();
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ noServer: true });
// 拦截升级请求
server.on('upgrade', (request, socket, head) => {
try {
// 从请求URL中获取令牌
const url = new URL(request.url!, `http://${request.headers.host}`);
const token = url.searchParams.get('token');
if (!token) {
logger.warn('WebSocket连接尝试未提供令牌');
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// 验证令牌
const secret = getAuthSecret();
try {
jwt.verify(token, secret);
// 令牌有效,允许升级连接
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
} catch (err) {
logger.warn(`无效的WebSocket认证令牌: ${err.message}`);
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
}
} catch (err) {
logger.error(`WebSocket认证错误: ${err}`);
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
socket.destroy();
}
});
wss.on('connection', handleConnection); wss.on('connection', handleConnection);

View file

@ -1,2 +0,0 @@
NEXT_PUBLIC_WS_URL=ws://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3001/api

62
ui/app/auth/page.tsx Normal file
View file

@ -0,0 +1,62 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export default function AuthPage() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('authToken', data.token);
router.push('/');
} else {
setError('密码错误');
}
} catch (err) {
setError('验证失败,请重试');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-light-primary dark:bg-dark-primary">
<div className="w-full max-w-md p-8 space-y-6 bg-light-secondary dark:bg-dark-secondary rounded-lg shadow-lg">
<h1 className="text-2xl font-bold text-center">访</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 border rounded-md bg-light-primary dark:bg-dark-primary"
placeholder="输入密码"
required
/>
</div>
{error && (
<p className="text-red-500 text-sm text-center">{error}</p>
)}
<button
type="submit"
className="w-full p-3 text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors"
>
</button>
</form>
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ import { Search } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getApiUrl, get } from '@/lib/api';
interface Discover { interface Discover {
title: string; title: string;
@ -19,25 +20,11 @@ const Page = () => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/discover`, { const data = await get<{ blogs: Discover[] }>(getApiUrl('/discover'));
method: 'GET', setDiscover(data.blogs.filter(blog => blog.thumbnail));
headers: { } catch (error) {
'Content-Type': 'application/json', console.error('Unable to fetch discovers:', error);
}, toast.error('Unable to fetch discovers');
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message);
}
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
setDiscover(data.blogs);
} catch (err: any) {
console.error('Error fetching data:', err.message);
toast.error('Error fetching data');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -88,9 +75,17 @@ const Page = () => {
<img <img
className="object-cover w-full aspect-video" className="object-cover w-full aspect-video"
src={ src={
new URL(item.thumbnail).origin + (() => {
new URL(item.thumbnail).pathname + try {
`?id=${new URL(item.thumbnail).searchParams.get('id')}` if (!item.thumbnail) return '/placeholder.jpg';
const url = new URL(item.thumbnail);
return url.origin + url.pathname +
(url.searchParams.get('id') ?
`?id=${url.searchParams.get('id')}` : '');
} catch (e) {
return item.thumbnail || '/placeholder.jpg';
}
})()
} }
alt={item.title} alt={item.title}
/> />

View file

@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import Sidebar from '@/components/Sidebar'; import Sidebar from '@/components/Sidebar';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import ThemeProvider from '@/components/theme/Provider'; import ThemeProvider from '@/components/theme/Provider';
import AuthCheck from '@/components/AuthCheck';
const montserrat = Montserrat({ const montserrat = Montserrat({
weight: ['300', '400', '500', '700'], weight: ['300', '400', '500', '700'],
@ -28,7 +29,9 @@ export default function RootLayout({
<html className="h-full" lang="en" suppressHydrationWarning> <html className="h-full" lang="en" suppressHydrationWarning>
<body className={cn('h-full', montserrat.className)}> <body className={cn('h-full', montserrat.className)}>
<ThemeProvider> <ThemeProvider>
<Sidebar>{children}</Sidebar> <AuthCheck>
<Sidebar>{children}</Sidebar>
</AuthCheck>
<Toaster <Toaster
toastOptions={{ toastOptions={{
unstyled: true, unstyled: true,

View file

@ -5,6 +5,8 @@ import { cn, formatTimeDifference } from '@/lib/utils';
import { BookOpenText, ClockIcon, Delete, ScanEye } from 'lucide-react'; import { BookOpenText, ClockIcon, Delete, ScanEye } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { getApiUrl, get } from '@/lib/api';
export interface Chat { export interface Chat {
id: string; id: string;
@ -21,17 +23,15 @@ const Page = () => {
const fetchChats = async () => { const fetchChats = async () => {
setLoading(true); setLoading(true);
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/chats`, { try {
method: 'GET', const data = await get<{ chats: Chat[] }>(getApiUrl('/chats'));
headers: { setChats(data.chats);
'Content-Type': 'application/json', } catch (error) {
}, console.error('获取聊天记录失败:', error);
}); toast.error('获取聊天记录失败');
} finally {
const data = await res.json(); setLoading(false);
}
setChats(data.chats);
setLoading(false);
}; };
fetchChats(); fetchChats();

View file

@ -7,13 +7,15 @@ import { Switch } from '@headlessui/react';
import ThemeSwitcher from '@/components/theme/Switcher'; import ThemeSwitcher from '@/components/theme/Switcher';
import { ImagesIcon, VideoIcon } from 'lucide-react'; import { ImagesIcon, VideoIcon } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { toast } from 'sonner';
import { getApiUrl, get, post } from '@/lib/api';
interface SettingsType { interface SettingsType {
chatModelProviders: { chatModelProviders: {
[key: string]: [Record<string, any>]; [key: string]: Record<string, any>[];
}; };
embeddingModelProviders: { embeddingModelProviders: {
[key: string]: [Record<string, any>]; [key: string]: Record<string, any>[];
}; };
openaiApiKey: string; openaiApiKey: string;
groqApiKey: string; groqApiKey: string;
@ -116,63 +118,62 @@ const Page = () => {
useEffect(() => { useEffect(() => {
const fetchConfig = async () => { const fetchConfig = async () => {
setIsLoading(true); setIsLoading(true);
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`, { try {
headers: { const data = await get<SettingsType>(getApiUrl('/config'));
'Content-Type': 'application/json', setConfig(data);
},
});
const data = (await res.json()) as SettingsType; const chatModelProvidersKeys = Object.keys(data.chatModelProviders || {});
setConfig(data); const embeddingModelProvidersKeys = Object.keys(
data.embeddingModelProviders || {},
);
const chatModelProvidersKeys = Object.keys(data.chatModelProviders || {}); const defaultChatModelProvider =
const embeddingModelProvidersKeys = Object.keys( chatModelProvidersKeys.length > 0 ? chatModelProvidersKeys[0] : '';
data.embeddingModelProviders || {}, const defaultEmbeddingModelProvider =
); embeddingModelProvidersKeys.length > 0
? embeddingModelProvidersKeys[0]
: '';
const defaultChatModelProvider = const chatModelProvider =
chatModelProvidersKeys.length > 0 ? chatModelProvidersKeys[0] : ''; localStorage.getItem('chatModelProvider') ||
const defaultEmbeddingModelProvider = defaultChatModelProvider ||
embeddingModelProvidersKeys.length > 0 '';
? embeddingModelProvidersKeys[0] const chatModel =
: ''; localStorage.getItem('chatModel') ||
(data.chatModelProviders &&
data.chatModelProviders[chatModelProvider]?.length > 0
? data.chatModelProviders[chatModelProvider][0].name
: undefined) ||
'';
const embeddingModelProvider =
localStorage.getItem('embeddingModelProvider') ||
defaultEmbeddingModelProvider ||
'';
const embeddingModel =
localStorage.getItem('embeddingModel') ||
(data.embeddingModelProviders &&
data.embeddingModelProviders[embeddingModelProvider]?.[0].name) ||
'';
const chatModelProvider = setSelectedChatModelProvider(chatModelProvider);
localStorage.getItem('chatModelProvider') || setSelectedChatModel(chatModel);
defaultChatModelProvider || setSelectedEmbeddingModelProvider(embeddingModelProvider);
''; setSelectedEmbeddingModel(embeddingModel);
const chatModel = setChatModels(data.chatModelProviders || {});
localStorage.getItem('chatModel') || setEmbeddingModels(data.embeddingModelProviders || {});
(data.chatModelProviders &&
data.chatModelProviders[chatModelProvider]?.length > 0
? data.chatModelProviders[chatModelProvider][0].name
: undefined) ||
'';
const embeddingModelProvider =
localStorage.getItem('embeddingModelProvider') ||
defaultEmbeddingModelProvider ||
'';
const embeddingModel =
localStorage.getItem('embeddingModel') ||
(data.embeddingModelProviders &&
data.embeddingModelProviders[embeddingModelProvider]?.[0].name) ||
'';
setSelectedChatModelProvider(chatModelProvider); setAutomaticImageSearch(
setSelectedChatModel(chatModel); localStorage.getItem('autoImageSearch') === 'true',
setSelectedEmbeddingModelProvider(embeddingModelProvider); );
setSelectedEmbeddingModel(embeddingModel); setAutomaticVideoSearch(
setChatModels(data.chatModelProviders || {}); localStorage.getItem('autoVideoSearch') === 'true',
setEmbeddingModels(data.embeddingModelProviders || {}); );
} catch (error) {
setAutomaticImageSearch( console.error('获取配置失败:', error);
localStorage.getItem('autoImageSearch') === 'true', toast.error('获取配置失败');
); } finally {
setAutomaticVideoSearch( setIsLoading(false);
localStorage.getItem('autoVideoSearch') === 'true', }
);
setIsLoading(false);
}; };
fetchConfig(); fetchConfig();
@ -187,39 +188,15 @@ const Page = () => {
[key]: value, [key]: value,
} as SettingsType; } as SettingsType;
const response = await fetch( await post(getApiUrl('/config'), updatedConfig);
`${process.env.NEXT_PUBLIC_API_URL}/config`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedConfig),
},
);
if (!response.ok) {
throw new Error('Failed to update config');
}
setConfig(updatedConfig); setConfig(updatedConfig);
if ( if (
key.toLowerCase().includes('api') || key.toLowerCase().includes('api') ||
key.toLowerCase().includes('url') key.toLowerCase().includes('url')
) { ) {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/config`, { const data = await get<SettingsType>(getApiUrl('/config'));
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error('Failed to fetch updated config');
}
const data = await res.json();
setChatModels(data.chatModelProviders || {}); setChatModels(data.chatModelProviders || {});
setEmbeddingModels(data.embeddingModelProviders || {}); setEmbeddingModels(data.embeddingModelProviders || {});
@ -332,13 +309,11 @@ const Page = () => {
} else if (key === 'embeddingModel') { } else if (key === 'embeddingModel') {
localStorage.setItem('embeddingModel', value); localStorage.setItem('embeddingModel', value);
} }
} catch (err) { } catch (error) {
console.error('Failed to save:', err); console.error('保存配置失败:', error);
setConfig((prev) => ({ ...prev! })); toast.error('保存配置失败');
} finally { } finally {
setTimeout(() => { setSavingStates((prev) => ({ ...prev, [key]: false }));
setSavingStates((prev) => ({ ...prev, [key]: false }));
}, 500);
} }
}; };

View file

@ -0,0 +1,65 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
export default function AuthCheck({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
// 检查用户是否已经通过认证
const authToken = localStorage.getItem('authToken');
// 如果当前路径是认证页面,不需要重定向
if (pathname === '/auth') {
setIsAuthenticated(true);
return;
}
// 如果未认证且不在认证页面,重定向到认证页面
if (!authToken) {
router.push('/auth');
setIsAuthenticated(false);
} else {
// 验证令牌有效性
checkTokenValidity(authToken);
}
}, [pathname, router]);
// 验证令牌有效性
const checkTokenValidity = async (token: string) => {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/verify-token`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
setIsAuthenticated(true);
} else {
// 令牌无效,清除存储并重定向到登录页
localStorage.removeItem('authToken');
router.push('/auth');
setIsAuthenticated(false);
}
} catch (error) {
console.error('验证令牌时出错:', error);
setIsAuthenticated(true); // 网络错误时暂时允许访问
}
};
// 如果认证状态尚未确定,显示加载状态
if (isAuthenticated === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-light-primary dark:bg-dark-primary">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
);
}
// 如果在认证页面或已认证,显示子组件
return <>{children}</>;
}

View file

@ -12,6 +12,7 @@ import { getSuggestions } from '@/lib/actions';
import { Settings } from 'lucide-react'; import { Settings } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import NextError from 'next/error'; import NextError from 'next/error';
import { getApiUrl, get } from '@/lib/api';
export type Message = { export type Message = {
messageId: string; messageId: string;
@ -71,20 +72,10 @@ const useSocket = (
localStorage.setItem('autoVideoSearch', 'false'); localStorage.setItem('autoVideoSearch', 'false');
} }
const providers = await fetch( const providers = await get<{
`${process.env.NEXT_PUBLIC_API_URL}/models`, chatModelProviders: Record<string, Record<string, any>>,
{ embeddingModelProviders: Record<string, Record<string, any>>
headers: { }>(getApiUrl('/models'));
'Content-Type': 'application/json',
},
},
).then(async (res) => {
if (!res.ok)
throw new Error(
`Failed to fetch models: ${res.status} ${res.statusText}`,
);
return res.json();
});
if ( if (
!chatModel || !chatModel ||
@ -201,6 +192,12 @@ const useSocket = (
searchParams.append('embeddingModel', embeddingModel!); searchParams.append('embeddingModel', embeddingModel!);
searchParams.append('embeddingModelProvider', embeddingModelProvider); searchParams.append('embeddingModelProvider', embeddingModelProvider);
// 添加认证令牌
const authToken = localStorage.getItem('authToken');
if (authToken) {
searchParams.append('token', authToken);
}
wsURL.search = searchParams.toString(); wsURL.search = searchParams.toString();
@ -315,55 +312,45 @@ const loadMessages = async (
setFiles: (files: File[]) => void, setFiles: (files: File[]) => void,
setFileIds: (fileIds: string[]) => void, setFileIds: (fileIds: string[]) => void,
) => { ) => {
const res = await fetch( try {
`${process.env.NEXT_PUBLIC_API_URL}/chats/${chatId}`, const data = await get<any>(getApiUrl(`/chats/${chatId}`));
{
method: 'GET', const messages = data.messages.map((msg: any) => {
headers: { return {
'Content-Type': 'application/json', ...msg,
}, ...JSON.parse(msg.metadata),
}, };
); }) as Message[];
if (res.status === 404) { setMessages(messages);
setNotFound(true);
const history = messages.map((msg) => {
return [msg.role, msg.content];
}) as [string, string][];
console.debug(new Date(), 'app:messages_loaded');
document.title = messages[0].content;
const files = data.chat.files.map((file: any) => {
return {
fileName: file.name,
fileExtension: file.name.split('.').pop(),
fileId: file.fileId,
};
});
setFiles(files);
setFileIds(files.map((file: File) => file.fileId));
setChatHistory(history);
setFocusMode(data.chat.focusMode);
setIsMessagesLoaded(true); setIsMessagesLoaded(true);
return; } catch (error) {
console.debug(new Date(), 'ws:error', error);
setIsMessagesLoaded(true);
setNotFound(true);
} }
const data = await res.json();
const messages = data.messages.map((msg: any) => {
return {
...msg,
...JSON.parse(msg.metadata),
};
}) as Message[];
setMessages(messages);
const history = messages.map((msg) => {
return [msg.role, msg.content];
}) as [string, string][];
console.debug(new Date(), 'app:messages_loaded');
document.title = messages[0].content;
const files = data.chat.files.map((file: any) => {
return {
fileName: file.name,
fileExtension: file.name.split('.').pop(),
fileId: file.fileId,
};
});
setFiles(files);
setFileIds(files.map((file: File) => file.fileId));
setChatHistory(history);
setFocusMode(data.chat.focusMode);
setIsMessagesLoaded(true);
}; };
const ChatWindow = ({ id }: { id?: string }) => { const ChatWindow = ({ id }: { id?: string }) => {

View file

@ -1,4 +1,4 @@
import { Trash } from 'lucide-react'; import { TrashIcon } from 'lucide-react';
import { import {
Description, Description,
Dialog, Dialog,
@ -11,6 +11,8 @@ import {
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Chat } from '@/app/library/page'; import { Chat } from '@/app/library/page';
import { useRouter } from 'next/navigation';
import { getApiUrl, del } from '@/lib/api';
const DeleteChat = ({ const DeleteChat = ({
chatId, chatId,
@ -25,36 +27,27 @@ const DeleteChat = ({
}) => { }) => {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const router = useRouter();
const handleDelete = async () => { const handleDelete = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch( await del(getApiUrl(`/chats/${chatId}`));
`${process.env.NEXT_PUBLIC_API_URL}/chats/${chatId}`,
{ // 从列表中移除聊天
method: 'DELETE', setChats(chats.filter((chat) => chat.id !== chatId));
headers: {
'Content-Type': 'application/json',
},
},
);
if (res.status != 200) {
throw new Error('Failed to delete chat');
}
const newChats = chats.filter((chat) => chat.id !== chatId);
setChats(newChats);
if (redirect) { if (redirect) {
window.location.href = '/'; router.push('/');
} }
} catch (err: any) {
toast.error(err.message); toast.success('聊天已删除');
} catch (error) {
console.error('删除聊天失败:', error);
toast.error('删除聊天失败');
} finally { } finally {
setConfirmationDialogOpen(false);
setLoading(false); setLoading(false);
setConfirmationDialogOpen(false);
} }
}; };
@ -66,7 +59,7 @@ const DeleteChat = ({
}} }}
className="bg-transparent text-red-400 hover:scale-105 transition duration-200" className="bg-transparent text-red-400 hover:scale-105 transition duration-200"
> >
<Trash size={17} /> <TrashIcon size={17} />
</button> </button>
<Transition appear show={confirmationDialogOpen} as={Fragment}> <Transition appear show={confirmationDialogOpen} as={Fragment}>
<Dialog <Dialog

View file

@ -41,8 +41,18 @@ const Attach = ({
data.append('embedding_model_provider', embeddingModelProvider!); data.append('embedding_model_provider', embeddingModelProvider!);
data.append('embedding_model', embeddingModel!); data.append('embedding_model', embeddingModel!);
// 获取认证令牌
const authToken = localStorage.getItem('authToken');
// 创建headers对象添加认证令牌
const headers: HeadersInit = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploads`, { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploads`, {
method: 'POST', method: 'POST',
headers,
body: data, body: data,
}); });

View file

@ -39,8 +39,18 @@ const AttachSmall = ({
data.append('embedding_model_provider', embeddingModelProvider!); data.append('embedding_model_provider', embeddingModelProvider!);
data.append('embedding_model', embeddingModel!); data.append('embedding_model', embeddingModel!);
// 获取认证令牌
const authToken = localStorage.getItem('authToken');
// 创建headers对象添加认证令牌
const headers: HeadersInit = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploads`, { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/uploads`, {
method: 'POST', method: 'POST',
headers,
body: data, body: data,
}); });

View file

@ -1,9 +1,11 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
import { ImagesIcon, PlusIcon } from 'lucide-react'; import { ImagesIcon, PlusIcon } from 'lucide-react';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import Lightbox from 'yet-another-react-lightbox'; import Lightbox from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css'; import 'yet-another-react-lightbox/styles.css';
import { Message } from './ChatWindow'; import { Message } from './ChatWindow';
import { getApiUrl, post } from '@/lib/api';
import { toast } from 'sonner';
type Image = { type Image = {
url: string; url: string;
@ -37,34 +39,25 @@ const SearchImages = ({
const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL'); const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL');
const customOpenAIKey = localStorage.getItem('openAIApiKey'); const customOpenAIKey = localStorage.getItem('openAIApiKey');
const res = await fetch( const data = await post<{ images: Image[] }>(
`${process.env.NEXT_PUBLIC_API_URL}/images`, getApiUrl('/images'),
{ {
method: 'POST', query,
headers: { chatHistory,
'Content-Type': 'application/json', chatModel: {
provider: chatModelProvider,
model: chatModel,
...(chatModelProvider === 'custom_openai' && {
customOpenAIKey,
customOpenAIBaseURL,
}),
}, },
body: JSON.stringify({ }
query: query,
chatHistory: chatHistory,
chatModel: {
provider: chatModelProvider,
model: chatModel,
...(chatModelProvider === 'custom_openai' && {
customOpenAIBaseURL: customOpenAIBaseURL,
customOpenAIKey: customOpenAIKey,
}),
},
}),
},
); );
const data = await res.json(); setImages(data.images);
const images = data.images ?? [];
setImages(images);
setSlides( setSlides(
images.map((image: Image) => { data.images.map((image: Image) => {
return { return {
src: image.img_src, src: image.img_src,
}; };

View file

@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox'; import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css'; import 'yet-another-react-lightbox/styles.css';
import { Message } from './ChatWindow'; import { Message } from './ChatWindow';
import { getApiUrl, post } from '@/lib/api';
type Video = { type Video = {
url: string; url: string;
@ -52,40 +53,31 @@ const Searchvideos = ({
const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL'); const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL');
const customOpenAIKey = localStorage.getItem('openAIApiKey'); const customOpenAIKey = localStorage.getItem('openAIApiKey');
const res = await fetch( const data = await post<{ videos: Video[] }>(
`${process.env.NEXT_PUBLIC_API_URL}/videos`, getApiUrl('/videos'),
{ {
method: 'POST', query,
headers: { chatHistory,
'Content-Type': 'application/json', chatModel: {
provider: chatModelProvider,
model: chatModel,
...(chatModelProvider === 'custom_openai' && {
customOpenAIKey,
customOpenAIBaseURL,
}),
}, },
body: JSON.stringify({ }
query: query,
chatHistory: chatHistory,
chatModel: {
provider: chatModelProvider,
model: chatModel,
...(chatModelProvider === 'custom_openai' && {
customOpenAIBaseURL: customOpenAIBaseURL,
customOpenAIKey: customOpenAIKey,
}),
},
}),
},
); );
const data = await res.json(); setVideos(data.videos || []);
const videos = data.videos ?? [];
setVideos(videos);
setSlides( setSlides(
videos.map((video: Video) => { data.videos.map((video: Video) => {
return { return {
type: 'video-slide', type: 'video-slide',
iframe_src: video.iframe_src, iframe_src: video.iframe_src,
src: video.img_src, src: video.img_src,
}; };
}), })
); );
setLoading(false); setLoading(false);
}} }}

View file

@ -1,4 +1,5 @@
import { Message } from '@/components/ChatWindow'; import { Message } from '@/components/ChatWindow';
import { getApiUrl, post } from './api';
export const getSuggestions = async (chatHisory: Message[]) => { export const getSuggestions = async (chatHisory: Message[]) => {
const chatModel = localStorage.getItem('chatModel'); const chatModel = localStorage.getItem('chatModel');
@ -7,12 +8,9 @@ export const getSuggestions = async (chatHisory: Message[]) => {
const customOpenAIKey = localStorage.getItem('openAIApiKey'); const customOpenAIKey = localStorage.getItem('openAIApiKey');
const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL'); const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL');
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/suggestions`, { const data = await post<{ suggestions: string[] }>(
method: 'POST', getApiUrl('/suggestions'),
headers: { {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chatHistory: chatHisory, chatHistory: chatHisory,
chatModel: { chatModel: {
provider: chatModelProvider, provider: chatModelProvider,
@ -22,10 +20,8 @@ export const getSuggestions = async (chatHisory: Message[]) => {
customOpenAIBaseURL, customOpenAIBaseURL,
}), }),
}, },
}), }
}); );
const data = (await res.json()) as { suggestions: string[] };
return data.suggestions; return data.suggestions;
}; };

96
ui/lib/api.ts Normal file
View file

@ -0,0 +1,96 @@
/**
* API请求工具函数
*/
// 基本API请求函数
export async function fetchWithToken(
url: string,
options: RequestInit = {}
): Promise<Response> {
// 从本地存储获取令牌
const token = localStorage.getItem('authToken');
// 准备请求头
const headers = {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers,
};
// 发送请求
return fetch(url, {
...options,
headers,
});
}
// GET请求
export async function get<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetchWithToken(url, {
method: 'GET',
...options,
});
if (!response.ok) {
throw new Error(`API请求失败: ${response.status}`);
}
return response.json();
}
// POST请求
export async function post<T>(
url: string,
data: any,
options: RequestInit = {}
): Promise<T> {
const response = await fetchWithToken(url, {
method: 'POST',
body: JSON.stringify(data),
...options,
});
if (!response.ok) {
throw new Error(`API请求失败: ${response.status}`);
}
return response.json();
}
// PUT请求
export async function put<T>(
url: string,
data: any,
options: RequestInit = {}
): Promise<T> {
const response = await fetchWithToken(url, {
method: 'PUT',
body: JSON.stringify(data),
...options,
});
if (!response.ok) {
throw new Error(`API请求失败: ${response.status}`);
}
return response.json();
}
// DELETE请求
export async function del<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetchWithToken(url, {
method: 'DELETE',
...options,
});
if (!response.ok) {
throw new Error(`API请求失败: ${response.status}`);
}
return response.json();
}
// 获取完整API URL
export function getApiUrl(path: string): string {
return `${process.env.NEXT_PUBLIC_API_URL}${path.startsWith('/') ? path : `/${path}`}`;
}

6961
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1393
yarn.lock

File diff suppressed because it is too large Load diff