Add Auth for WebPage and APIs
This commit is contained in:
parent
e6b87f89ec
commit
5e6d0e0ee6
27 changed files with 15384 additions and 1720 deletions
|
|
@ -4,51 +4,5 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./searxng:/etc/searxng:rw
|
- ./searxng:/etc/searxng:rw
|
||||||
ports:
|
ports:
|
||||||
- 4000:8080
|
- 3666:8080
|
||||||
networks:
|
|
||||||
- perplexica-network
|
|
||||||
restart: unless-stopped
|
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
6720
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
11
src/app.ts
11
src/app.ts
|
|
@ -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}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
54
src/middleware/auth.ts
Normal 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
46
src/routes/auth.ts
Normal 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;
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
62
ui/app/auth/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,20 +188,7 @@ 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);
|
||||||
|
|
||||||
|
|
@ -208,18 +196,7 @@ const Page = () => {
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
65
ui/components/AuthCheck.tsx
Normal file
65
ui/components/AuthCheck.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
|
|
@ -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 ||
|
||||||
|
|
@ -202,6 +193,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();
|
||||||
|
|
||||||
const ws = new WebSocket(wsURL.toString());
|
const ws = new WebSocket(wsURL.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',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res.status === 404) {
|
const messages = data.messages.map((msg: any) => {
|
||||||
setNotFound(true);
|
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);
|
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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (res.status != 200) {
|
// 从列表中移除聊天
|
||||||
throw new Error('Failed to delete chat');
|
setChats(chats.filter((chat) => chat.id !== chatId));
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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
96
ui/lib/api.ts
Normal 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
6961
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
1087
ui/yarn.lock
1087
ui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue