system commit
This commit is contained in:
9
backend/.env.example
Normal file
9
backend/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
PORT=3000
|
||||
MONGODB_URI=mongodb://localhost:27017/multi_llm_chat
|
||||
OPENAI_API_KEY=your_openai_key_here
|
||||
# 可选:自定义 OpenAI Base
|
||||
OPENAI_BASE_URL=https://api.openai.com
|
||||
|
||||
# DeepSeek 兼容 (同 OpenAI 协议)
|
||||
DEEPSEEK_API_KEY=your_deepseek_key_here
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
3
backend/.gitignore
vendored
Normal file
3
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
31
backend/README.md
Normal file
31
backend/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 后端服务
|
||||
|
||||
Node.js + Express + TypeScript + Mongoose + OpenAI 示例。
|
||||
|
||||
## 环境变量
|
||||
复制 `.env.example` 为 `.env` 并填写:
|
||||
|
||||
```
|
||||
PORT=3000
|
||||
MONGODB_URI=mongodb://localhost:27017/multi_llm_chat
|
||||
OPENAI_API_KEY=sk-...
|
||||
# 可选 OpenAI base (代理): OPENAI_BASE_URL=https://api.openai.com
|
||||
DEEPSEEK_API_KEY=sk-deepseek...
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
```
|
||||
|
||||
## 运行
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 接口
|
||||
- POST /api/chat { modelId, messages } => { text }
|
||||
- POST /api/chat/stream (SSE) data: <chunk> 直到 data: [DONE]
|
||||
- POST /api/conversations 保存对话
|
||||
- GET /api/conversations/:id 读取对话
|
||||
- GET /api/health 健康检查
|
||||
|
||||
## 说明
|
||||
已接入 OpenAI 与 DeepSeek(DeepSeek 通过 model 名包含 "deepseek" 自动匹配,使用 OpenAI 协议 /v1/chat/completions)。可继续在 `llmService.ts` 中扩展更多 provider 逻辑。
|
30
backend/package.json
Normal file
30
backend/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "multi-llm-chat-backend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^8.5.3",
|
||||
"openai": "^4.56.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.15.7",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
30
backend/src/index.ts
Normal file
30
backend/src/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import mongoose from 'mongoose';
|
||||
import chatRoutes from './routes/chat.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api', chatRoutes);
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true, time: new Date().toISOString() }));
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
const mongo = process.env.MONGODB_URI || 'mongodb://localhost:27017/multi_llm_chat';
|
||||
console.log('Connecting MongoDB ->', mongo);
|
||||
await mongoose.connect(mongo);
|
||||
app.listen(PORT, () => console.log('Server listening on ' + PORT));
|
||||
} catch (e) {
|
||||
console.error('Failed to start server', e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
start();
|
29
backend/src/middleware/auth.ts
Normal file
29
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
export interface AuthRequest extends Request { userId?: string; }
|
||||
|
||||
export function auth(required = true) {
|
||||
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const header = req.headers.authorization;
|
||||
if (!header) {
|
||||
if (required) return res.status(401).json({ error: 'NO_AUTH' });
|
||||
return next();
|
||||
}
|
||||
const token = header.replace(/^Bearer\s+/i, '');
|
||||
try {
|
||||
const secret = process.env.JWT_SECRET || 'dev_secret';
|
||||
const payload = jwt.verify(token, secret) as { uid: string };
|
||||
req.userId = payload.uid;
|
||||
next();
|
||||
} catch (e) {
|
||||
if (required) return res.status(401).json({ error: 'BAD_TOKEN' });
|
||||
next();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function signToken(uid: string) {
|
||||
const secret = process.env.JWT_SECRET || 'dev_secret';
|
||||
return jwt.sign({ uid }, secret, { expiresIn: '7d' });
|
||||
}
|
31
backend/src/models/Conversation.ts
Normal file
31
backend/src/models/Conversation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Schema, model, Document } from 'mongoose';
|
||||
|
||||
export interface IMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export interface IConversation extends Document {
|
||||
title?: string;
|
||||
userId?: string;
|
||||
modelId: string;
|
||||
messages: IMessage[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const MessageSchema = new Schema<IMessage>({
|
||||
role: { type: String, required: true },
|
||||
content: { type: String, required: true },
|
||||
createdAt: { type: Date, default: Date.now }
|
||||
}, { _id: false });
|
||||
|
||||
const ConversationSchema = new Schema<IConversation>({
|
||||
title: String,
|
||||
userId: { type: String, index: true },
|
||||
modelId: { type: String, required: true },
|
||||
messages: { type: [MessageSchema], default: [] },
|
||||
}, { timestamps: true });
|
||||
|
||||
export const Conversation = model<IConversation>('Conversation', ConversationSchema);
|
15
backend/src/models/User.ts
Normal file
15
backend/src/models/User.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Schema, model, Document } from 'mongoose';
|
||||
|
||||
export interface IUser extends Document {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const UserSchema = new Schema<IUser>({
|
||||
email: { type: String, required: true, unique: true, index: true },
|
||||
passwordHash: { type: String, required: true }
|
||||
}, { timestamps: true });
|
||||
|
||||
export const User = model<IUser>('User', UserSchema);
|
28
backend/src/routes/auth.ts
Normal file
28
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { User } from '../models/User.js';
|
||||
import { signToken } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) return res.status(400).json({ error: 'MISSING_FIELDS' });
|
||||
const exist = await User.findOne({ email });
|
||||
if (exist) return res.status(409).json({ error: 'EMAIL_EXISTS' });
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await User.create({ email, passwordHash });
|
||||
return res.json({ token: signToken(user.id), user: { id: user.id, email: user.email } });
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) return res.status(400).json({ error: 'MISSING_FIELDS' });
|
||||
const user = await User.findOne({ email });
|
||||
if (!user) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||
return res.json({ token: signToken(user.id), user: { id: user.id, email: user.email } });
|
||||
});
|
||||
|
||||
export default router;
|
100
backend/src/routes/chat.ts
Normal file
100
backend/src/routes/chat.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { Conversation } from '../models/Conversation.js';
|
||||
import { streamChat, oneShot, StreamChunk } from '../services/llmService.js';
|
||||
import { auth, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/chat', auth(false), async (req: AuthRequest, res: Response) => {
|
||||
const { modelId, messages, conversationId, save } = req.body || {};
|
||||
try {
|
||||
const text = await oneShot(modelId, messages);
|
||||
let convId = conversationId;
|
||||
if (save && req.userId) {
|
||||
if (convId) {
|
||||
await Conversation.findByIdAndUpdate(convId, { $push: { messages: { role: 'assistant', content: text } } });
|
||||
} else {
|
||||
const cleaned = (messages||[]).filter((m:any)=>m && typeof m.content==='string' && m.content.trim().length>0);
|
||||
const title = (cleaned?.[0]?.content || text.slice(0,30)) || '新对话';
|
||||
const conv = await Conversation.create({ userId: req.userId, modelId, title, messages: [...cleaned, { role: 'assistant', content: text }] });
|
||||
convId = conv.id;
|
||||
}
|
||||
}
|
||||
res.json({ text, conversationId: convId });
|
||||
} catch (e:any) {
|
||||
console.error(e);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/chat/stream', auth(false), async (req: AuthRequest, res: Response) => {
|
||||
const { modelId, messages, conversationId, save } = req.body || {};
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders?.();
|
||||
console.log('[stream] start model=%s messages=%d conv=%s', modelId, (messages||[]).length, conversationId || 'new');
|
||||
try {
|
||||
let buffer = '';
|
||||
for await (const chunk of streamChat(modelId, messages) as AsyncGenerator<StreamChunk>) {
|
||||
if (chunk.done) {
|
||||
if (save && req.userId) {
|
||||
if (buffer.trim()) { // 只在有内容时保存
|
||||
if (conversationId) {
|
||||
await Conversation.findByIdAndUpdate(conversationId, { $push: { messages: { role: 'assistant', content: buffer } } });
|
||||
} else {
|
||||
const cleaned = (messages||[]).filter((m:any)=>m && typeof m.content==='string' && m.content.trim().length>0);
|
||||
const title = (cleaned?.[0]?.content || buffer.slice(0,30)) || '新对话';
|
||||
const conv = await Conversation.create({ userId: req.userId, modelId, title, messages: [...cleaned, { role: 'assistant', content: buffer }] });
|
||||
res.write('data: {"conversationId":"'+conv.id+'"}\n\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
res.write('data: [DONE]\n\n');
|
||||
} else {
|
||||
if (chunk.kind === 'answer') buffer += chunk.text;
|
||||
const payload = chunk.kind ? JSON.stringify({ kind: chunk.kind, text: chunk.text }) : chunk.text;
|
||||
res.write('data: ' + payload.replace(/\n/g, '\\n') + '\n\n');
|
||||
(res as any).flush?.();
|
||||
}
|
||||
}
|
||||
} catch (e:any) {
|
||||
console.error(e);
|
||||
res.write('data: [ERROR] ' + (e.message || 'unknown') + '\n\n');
|
||||
} finally {
|
||||
console.log('[stream] end model=%s', modelId);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/conversations', auth(), async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { modelId, messages, title } = req.body;
|
||||
const conv = await Conversation.create({ userId: req.userId, modelId, messages, title });
|
||||
res.json(conv);
|
||||
} catch (e:any) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/conversations/:id', auth(), async (req: AuthRequest, res: Response) => {
|
||||
const conv = await Conversation.findOne({ _id: req.params.id, userId: req.userId });
|
||||
if (!conv) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(conv);
|
||||
});
|
||||
|
||||
router.get('/conversations', auth(), async (req: AuthRequest, res: Response) => {
|
||||
const list = await Conversation.find({ userId: req.userId }).sort({ updatedAt: -1 }).limit(50);
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
router.delete('/conversations/:id', auth(), async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
await Conversation.deleteOne({ _id: req.params.id, userId: req.userId });
|
||||
res.json({ ok: true });
|
||||
} catch (e:any) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
75
backend/src/services/llmService.ts
Normal file
75
backend/src/services/llmService.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import OpenAI from 'openai';
|
||||
|
||||
export interface ProviderConfig {
|
||||
name: 'openai' | 'deepseek';
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
}
|
||||
|
||||
function detectProvider(model: string): ProviderConfig {
|
||||
if (/deepseek/i.test(model)) {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY || '';
|
||||
if (!apiKey) throw new Error('Missing DEEPSEEK_API_KEY');
|
||||
return {
|
||||
name: 'deepseek',
|
||||
apiKey,
|
||||
baseURL: process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com'
|
||||
};
|
||||
}
|
||||
const apiKey = process.env.OPENAI_API_KEY || '';
|
||||
if (!apiKey) throw new Error('Missing OPENAI_API_KEY');
|
||||
return {
|
||||
name: 'openai',
|
||||
apiKey,
|
||||
baseURL: process.env.OPENAI_BASE_URL || process.env.OPENAI_API_BASE || 'https://api.openai.com/v1'
|
||||
};
|
||||
}
|
||||
|
||||
function buildClient(cfg: ProviderConfig) {
|
||||
// 允许用户传入已经包含 /v1 的 baseURL,避免重复 /v1/v1
|
||||
let base = cfg.baseURL.replace(/\/$/, '');
|
||||
if (!/\/v1$/i.test(base)) base += '/v1';
|
||||
return new OpenAI({ apiKey: cfg.apiKey, baseURL: base });
|
||||
}
|
||||
|
||||
export interface StreamChunk { text: string; done?: boolean; kind?: 'reasoning' | 'answer'; }
|
||||
|
||||
export async function *streamChat(model: string, messages: { role: 'user'|'assistant'|'system'; content: string }[]): AsyncGenerator<StreamChunk> {
|
||||
messages = ensureSystem(messages) as { role: 'user'|'assistant'|'system'; content: string }[];
|
||||
const cfg = detectProvider(model);
|
||||
const client = buildClient(cfg);
|
||||
const stream = await client.chat.completions.create({
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: 0.7,
|
||||
});
|
||||
for await (const part of stream) {
|
||||
const choice = part.choices?.[0];
|
||||
// Deepseek reasoning model: reasoning_content or similar field (SDK may expose in delta.extra)
|
||||
const reasoning: any = (choice as any)?.delta?.reasoning_content || (choice as any)?.delta?.reasoning;
|
||||
if (reasoning) yield { text: reasoning, kind: 'reasoning' };
|
||||
const delta = choice?.delta?.content;
|
||||
if (delta) yield { text: delta, kind: 'answer' } as StreamChunk;
|
||||
}
|
||||
yield { text: '', done: true } as StreamChunk;
|
||||
}
|
||||
|
||||
export async function oneShot(model: string, messages: { role: 'user'|'assistant'|'system'; content: string }[]) {
|
||||
messages = ensureSystem(messages) as { role: 'user'|'assistant'|'system'; content: string }[];
|
||||
const cfg = detectProvider(model);
|
||||
const client = buildClient(cfg);
|
||||
const completion = await client.chat.completions.create({ model, messages, stream: false });
|
||||
const first = completion.choices[0];
|
||||
const reasoning: string | undefined = (first as any)?.message?.reasoning_content || (first as any)?.message?.reasoning;
|
||||
const content = first?.message?.content || '';
|
||||
return reasoning ? reasoning + '\n' + content : content;
|
||||
}
|
||||
|
||||
const DEFAULT_SYSTEM = process.env.SYSTEM_PROMPT || 'You are a helpful assistant.';
|
||||
function ensureSystem(msgs: { role: any; content: string }[]) {
|
||||
if (!msgs || !Array.isArray(msgs)) return [{ role: 'system', content: DEFAULT_SYSTEM }];
|
||||
const hasSystem = msgs.some(m => m.role === 'system');
|
||||
console.log('hasSystem', hasSystem);
|
||||
return hasSystem ? msgs : [{ role: 'system', content: DEFAULT_SYSTEM }, ...msgs];
|
||||
}
|
30
backend/src/services/openaiService.ts
Normal file
30
backend/src/services/openaiService.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import OpenAI from 'openai';
|
||||
|
||||
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||||
|
||||
export interface ChatCompletionChunk {
|
||||
text: string;
|
||||
done?: boolean;
|
||||
}
|
||||
|
||||
export async function *streamChat(model: string, messages: { role: 'user'|'assistant'|'system'; content: string }[]) {
|
||||
// 使用新的 responses API (若版本支持);否则回退到 chat.completions
|
||||
const stream = await client.chat.completions.create({
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: 0.7,
|
||||
});
|
||||
for await (const part of stream) {
|
||||
const delta = part.choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
yield { text: delta } as ChatCompletionChunk;
|
||||
}
|
||||
}
|
||||
yield { text: '', done: true };
|
||||
}
|
||||
|
||||
export async function oneShot(model: string, messages: { role: 'user'|'assistant'|'system'; content: string }[]) {
|
||||
const completion = await client.chat.completions.create({ model, messages });
|
||||
return completion.choices[0]?.message?.content || '';
|
||||
}
|
16
backend/tsconfig.json
Normal file
16
backend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"types": ["node"],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE=/api
|
||||
# 可自定义后端地址: VITE_API_BASE=http://localhost:3000/api
|
21
frontend/.eslintrc.cjs
Normal file
21
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2022: true, node: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:@typescript-eslint/recommended'
|
||||
],
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0
|
||||
}
|
||||
};
|
33
frontend/README.md
Normal file
33
frontend/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 多模型 AI 聊天前端
|
||||
|
||||
基于 Vite + Vue3 + TypeScript + Pinia + Vue Router 的多大模型可切换聊天应用基础骨架。
|
||||
|
||||
## 功能概览
|
||||
- 左侧侧边栏:路由导航 + 模型下拉切换
|
||||
- Chat 页面:
|
||||
- 基础消息列表
|
||||
- 流式输出 (SSE/Fetch 读取)
|
||||
- Shift+Enter 换行、Enter 发送
|
||||
- Settings 页面:简单的各 Provider API Key / Endpoint 输入 (本地存储)
|
||||
- About 页面:说明
|
||||
- 明暗主题切换(侧边栏按钮,通过 data-theme 切换 CSS 变量)
|
||||
|
||||
## 后端接口假设
|
||||
- POST `/api/chat/stream` (SSE 或 chunked 文本, 行以 `data: 内容` 开头, `[DONE]` 结束)
|
||||
- POST `/api/chat` 返回 `{ text: string }`
|
||||
|
||||
## 开发
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## TODO / 后续可扩展
|
||||
- 会话列表 & 历史存储
|
||||
- Markdown + 代码高亮 + 复制按钮
|
||||
- 模型配置动态拉取
|
||||
- 错误与重试、取消请求 (AbortController)
|
||||
- 工具调用 / 图像 / 文件消息类型
|
||||
- RAG 检索侧栏
|
||||
- 用户登录与鉴权
|
||||
```
|
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>AI 多模型聊天</title>
|
||||
<meta name="description" content="多大模型可切换的聊天站点" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "multi-llm-chat-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .ts,.tsx,.vue --fix",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.29",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
6
frontend/public/favicon.svg
Normal file
6
frontend/public/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" fill="none">
|
||||
<rect width="128" height="128" rx="24" fill="#1e1e24"/>
|
||||
<path d="M34 64c0-16.569 13.431-30 30-30s30 13.431 30 30-13.431 30-30 30" stroke="#3b82f6" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M64 34v60" stroke="#10b981" stroke-width="10" stroke-linecap="round"/>
|
||||
<circle cx="64" cy="64" r="12" fill="#f8fafc"/>
|
||||
</svg>
|
After Width: | Height: | Size: 457 B |
113
frontend/src/App.vue
Normal file
113
frontend/src/App.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="top">
|
||||
<h1 class="logo">AI Chat</h1>
|
||||
<nav>
|
||||
<!-- chat icon removed per request -->
|
||||
</nav>
|
||||
</div>
|
||||
<ConversationList @new="handleNewConv" />
|
||||
<div class="sidebar-bottom">
|
||||
<div class="avatar-area left-align">
|
||||
<button class="avatar-btn" @click="authStore.isAuthed ? goSettings() : goLogin()">
|
||||
<img v-if="authStore.user" :src="avatarUrl(userEmail)" alt="avatar" />
|
||||
<div v-else class="avatar-fallback">{{ userInitial }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="right-icons">
|
||||
<button class="icon-btn" title="设置" @click="goSettings">⚙️</button>
|
||||
<button class="icon-btn" title="关于" @click="goAbout">ℹ️</button>
|
||||
<button class="theme-toggle" @click="toggleTheme" :title="themeLabel">{{ themeStore.theme === 'dark' ? '🌙' : '☀️' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useModelStore, type ModelInfo } from './stores/modelStore';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useThemeStore } from './stores/themeStore';
|
||||
import { useConversationStore } from './stores/conversationStore';
|
||||
import ConversationList from './components/ConversationList.vue';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const modelStore = useModelStore();
|
||||
const themeStore = useThemeStore();
|
||||
const convStore = useConversationStore();
|
||||
const authStore = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { currentModel } = storeToRefs(modelStore);
|
||||
const models = ref(modelStore.supportedModels);
|
||||
const themeLabel = computed(() => themeStore.theme === 'dark' ? '切换为浅色' : '切换为深色');
|
||||
const menuOpen = ref(false);
|
||||
|
||||
const userEmail = computed(() => authStore.user?.email || '');
|
||||
const userInitial = computed(() => authStore.user ? authStore.user.email[0] : '访');
|
||||
|
||||
function toggleTheme() { themeStore.toggle(); }
|
||||
function handleNewConv() { convStore.createNewConversation('已创建新会话(此消息不会保存)'); router.push('/chat'); }
|
||||
function goSettings() { router.push('/settings'); }
|
||||
function goAbout() { router.push('/about'); }
|
||||
function goLogin() { router.push('/login'); }
|
||||
|
||||
function handleModelChange() {
|
||||
modelStore.setCurrentModel(currentModel.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => modelStore.supportedModels,
|
||||
(v: ModelInfo[]) => { models.value = v; },
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
if (authStore.isAuthed) convStore.fetchList();
|
||||
|
||||
function avatarUrl(email: string) { return ''; }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif;
|
||||
}
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
padding: 16px;
|
||||
background: var(--sidebar-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
box-shadow: 2px 0 6px var(--shadow-color);
|
||||
}
|
||||
.sidebar-bottom { margin-top: auto; display:flex; justify-content:space-between; align-items:center; }
|
||||
.left-icons { display:flex; gap:8px; }
|
||||
.left-align { justify-content:flex-start; }
|
||||
.right-icons { display:flex; gap:8px; align-items:center; }
|
||||
.icon-btn { width:36px; height:36px; border-radius:8px; background:var(--input-bg); border:1px solid var(--input-border); display:inline-flex; align-items:center; justify-content:center; cursor:pointer; }
|
||||
.avatar-area { display:flex; gap:8px; align-items:center; }
|
||||
.avatar-btn { width:40px; height:40px; border-radius:50%; overflow:hidden; border:none; display:inline-flex; align-items:center; justify-content:center; background:var(--button-bg); color:#fff; }
|
||||
.avatar-fallback { font-size:14px; }
|
||||
.theme-toggle { width:36px; height:36px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; background:var(--input-bg); border:1px solid var(--input-border); cursor:pointer; }
|
||||
.logo { margin: 0 0 8px; font-size: 20px; }
|
||||
nav { display: flex; flex-direction: column; gap: 8px; }
|
||||
nav a { color: var(--text-color); opacity:.75; text-decoration: none; font-size: 14px; }
|
||||
nav a.router-link-active { opacity:1; font-weight: 600; }
|
||||
.model-switcher { margin-top: auto; }
|
||||
.theme-switcher { margin-top: 12px; }
|
||||
select, button { background: var(--input-bg); color: var(--text-color); border:1px solid var(--input-border); padding:6px 8px; border-radius:6px; cursor:pointer; }
|
||||
button { background: var(--button-bg); border:none; }
|
||||
button:hover { filter: brightness(1.05); }
|
||||
button:active { filter: brightness(.95); }
|
||||
.main { flex: 1; display: flex; flex-direction: column; }
|
||||
.main { overflow:hidden; }
|
||||
</style>
|
50
frontend/src/components/ConversationList.vue
Normal file
50
frontend/src/components/ConversationList.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="conv-list">
|
||||
<div class="header">
|
||||
<span>历史会话</span>
|
||||
<button type="button" @click="$emit('new')">新建</button>
|
||||
<button v-if="authed" type="button" @click="refresh">⟳</button>
|
||||
</div>
|
||||
<div v-if="!authed" class="hint">登录后可保存会话</div>
|
||||
<div v-else class="items" v-loading="loading">
|
||||
<div v-for="c in list" :key="c._id" :class="['item', currentId===c._id && 'active']" @click="select(c._id)">
|
||||
<div class="title">{{ c.title || '未命名' }}</div>
|
||||
<div class="meta">{{ c.modelId }} · {{ formatTime(c.updatedAt) }}</div>
|
||||
<button class="del" @click.stop="remove(c._id)">删除</button>
|
||||
</div>
|
||||
<div v-if="!list.length && !loading" class="empty">暂无会话</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useConversationStore } from '@/stores/conversationStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
const convStore = useConversationStore();
|
||||
const auth = useAuthStore();
|
||||
const list = computed(()=>convStore.list);
|
||||
const loading = computed(()=>convStore.loading);
|
||||
const currentId = computed(()=>convStore.currentId);
|
||||
const authed = computed(()=>auth.isAuthed);
|
||||
const router = useRouter();
|
||||
|
||||
function refresh() { convStore.fetchList(); }
|
||||
function select(id: string) { convStore.loadConversation(id); router.push('/chat'); }
|
||||
function formatTime(t: string) { return new Date(t).toLocaleString(); }
|
||||
function remove(id: string) { convStore.deleteConversation(id); }
|
||||
</script>
|
||||
<style scoped>
|
||||
.conv-list { display:flex; flex-direction:column; gap:8px; }
|
||||
.header { display:flex; gap:8px; align-items:center; font-size:13px; }
|
||||
.header button { background:var(--button-bg); color:#fff; border:none; padding:4px 8px; border-radius:4px; cursor:pointer; }
|
||||
.hint { font-size:12px; opacity:.6; }
|
||||
.items { display:flex; flex-direction:column; gap:4px; max-height:260px; overflow:auto; }
|
||||
.item { padding:6px 8px; border-radius:6px; cursor:pointer; background:var(--input-bg); display:flex; flex-direction:column; gap:2px; }
|
||||
.item.active { outline:2px solid var(--button-bg); }
|
||||
.title { font-size:13px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.meta { font-size:11px; opacity:.5; }
|
||||
.del { margin-left:auto; margin-top:6px; background:transparent; color:var(--text-color); border:1px solid var(--input-border); padding:4px 8px; border-radius:6px; cursor:pointer; }
|
||||
.empty { font-size:12px; opacity:.5; text-align:center; padding:12px 0; }
|
||||
</style>
|
14
frontend/src/main.ts
Normal file
14
frontend/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './styles/global.css';
|
||||
import { useThemeStore } from './stores/themeStore';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
// 初始化主题
|
||||
const themeStore = useThemeStore();
|
||||
themeStore.applyTheme();
|
20
frontend/src/router.ts
Normal file
20
frontend/src/router.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
import ChatView from './views/ChatView.vue';
|
||||
import SettingsView from './views/SettingsView.vue';
|
||||
import AboutView from './views/AboutView.vue';
|
||||
import LoginView from './views/LoginView.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/chat' },
|
||||
{ path: '/chat', component: ChatView },
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/settings', component: SettingsView },
|
||||
{ path: '/about', component: AboutView },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
55
frontend/src/services/chatService.ts
Normal file
55
frontend/src/services/chatService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import axios from 'axios';
|
||||
|
||||
interface SendMessage { role: 'user' | 'assistant'; content: string; }
|
||||
interface ChatRequest { modelId: string; messages: SendMessage[]; conversationId?: string | null; save?: boolean; }
|
||||
|
||||
export interface StreamPayload { kind?: 'reasoning' | 'answer'; text: string; conversationId?: string; }
|
||||
|
||||
// 这里演示一个 SSE / 流式的消费方式,后端需提供 /api/chat/stream 接口
|
||||
export async function *chatWithModel(req: ChatRequest, token?: string): AsyncGenerator<StreamPayload> {
|
||||
// 先尝试使用 fetch EventSource-like
|
||||
const resp = await fetch('/api/chat/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw new Error('网络错误 ' + resp.status);
|
||||
}
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const chunk = await reader.read();
|
||||
done = chunk.done || false;
|
||||
if (chunk.value) {
|
||||
const text = decoder.decode(chunk.value, { stream: !done });
|
||||
// 假设后端用 "data: ...\n\n" SSE 格式
|
||||
const parts = text.split(/\n\n/).filter(Boolean);
|
||||
for (const p of parts) {
|
||||
const m = p.match(/^data: (.*)$/m);
|
||||
if (m) {
|
||||
const payload = m[1];
|
||||
if (payload === '[DONE]') return;
|
||||
if (payload.startsWith('{') && payload.endsWith('}')) {
|
||||
try {
|
||||
const obj = JSON.parse(payload);
|
||||
yield obj;
|
||||
} catch { /* swallow */ }
|
||||
} else if (payload.startsWith('[ERROR]')) {
|
||||
throw new Error(payload.slice(7).trim());
|
||||
} else {
|
||||
yield { text: payload };
|
||||
}
|
||||
} else {
|
||||
yield { text: p };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendOnce(req: ChatRequest, token?: string): Promise<{ text: string; conversationId?: string; }> {
|
||||
const { data } = await axios.post('/api/chat', req, { headers: token ? { Authorization: 'Bearer ' + token } : {} });
|
||||
return data;
|
||||
}
|
5
frontend/src/shims-vue.d.ts
vendored
Normal file
5
frontend/src/shims-vue.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
50
frontend/src/stores/authStore.ts
Normal file
50
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
|
||||
interface UserInfo { id: string; email: string; }
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: UserInfo | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
token: localStorage.getItem('token'),
|
||||
user: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
getters: {
|
||||
isAuthed: (s) => !!s.token,
|
||||
},
|
||||
actions: {
|
||||
async login(email: string, password: string) {
|
||||
this.loading = true; this.error = null;
|
||||
try {
|
||||
const { data } = await axios.post('/api/auth/login', { email, password });
|
||||
this.token = data.token; localStorage.setItem('token', data.token);
|
||||
this.user = data.user ? { id: data.user.id, email: data.user.email } : { id: 'me', email };
|
||||
} catch (e:any) {
|
||||
this.error = e.response?.data?.error || e.message;
|
||||
throw e;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async register(email: string, password: string) {
|
||||
this.loading = true; this.error = null;
|
||||
try {
|
||||
const { data } = await axios.post('/api/auth/register', { email, password });
|
||||
this.token = data.token; localStorage.setItem('token', data.token);
|
||||
this.user = data.user ? { id: data.user.id, email: data.user.email } : { id: 'me', email };
|
||||
} catch (e:any) {
|
||||
this.error = e.response?.data?.error || e.message;
|
||||
throw e;
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
logout() { this.token = null; this.user = null; localStorage.removeItem('token'); }
|
||||
}
|
||||
});
|
66
frontend/src/stores/conversationStore.ts
Normal file
66
frontend/src/stores/conversationStore.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from './authStore';
|
||||
|
||||
export interface ConversationSummary { _id: string; title: string; modelId: string; updatedAt: string; }
|
||||
export interface ConversationMessage { role: 'user' | 'assistant' | 'system'; content: string; transient?: boolean }
|
||||
|
||||
interface ConversationState {
|
||||
list: ConversationSummary[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
currentId: string | null;
|
||||
messages: ConversationMessage[];
|
||||
}
|
||||
|
||||
export const useConversationStore = defineStore('conversation', {
|
||||
state: (): ConversationState => ({ list: [], loading: false, error: null, currentId: null, messages: [] }),
|
||||
actions: {
|
||||
async fetchList() {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.token) { this.list = []; return; }
|
||||
this.loading = true; this.error = null;
|
||||
try {
|
||||
const { data } = await axios.get('/api/conversations', { headers: { Authorization: 'Bearer ' + auth.token } });
|
||||
this.list = data;
|
||||
} catch (e:any) { this.error = e.response?.data?.error || e.message; }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
async loadConversation(id: string) {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.token) return;
|
||||
this.loading = true; this.error = null;
|
||||
try {
|
||||
const { data } = await axios.get('/api/conversations/' + id, { headers: { Authorization: 'Bearer ' + auth.token } });
|
||||
this.currentId = id;
|
||||
this.messages = data.messages || [];
|
||||
} catch (e:any) { this.error = e.response?.data?.error || e.message; }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
resetCurrent() { this.currentId = null; this.messages = []; },
|
||||
createNewConversation(notify?: string) {
|
||||
this.currentId = null;
|
||||
this.messages = [];
|
||||
if (notify) {
|
||||
this.messages.push({ role: 'system', content: notify, transient: true });
|
||||
}
|
||||
},
|
||||
pushMessage(m: ConversationMessage) { this.messages.push(m); },
|
||||
patchLastAssistant(content: string) {
|
||||
for (let i = this.messages.length - 1; i >=0; i--) {
|
||||
const msg = this.messages[i];
|
||||
if (msg.role === 'assistant') { msg.content = content; return; }
|
||||
}
|
||||
}
|
||||
,
|
||||
async deleteConversation(id: string) {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.token) return;
|
||||
try {
|
||||
await axios.delete('/api/conversations/' + id, { headers: { Authorization: 'Bearer ' + auth.token } });
|
||||
this.list = this.list.filter(l => l._id !== id);
|
||||
if (this.currentId === id) this.resetCurrent();
|
||||
} catch (e:any) { this.error = e.response?.data?.error || e.message; }
|
||||
}
|
||||
}
|
||||
});
|
44
frontend/src/stores/modelStore.ts
Normal file
44
frontend/src/stores/modelStore.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
provider: 'openai' | 'azureOpenAI' | 'anthropic' | 'google' | 'ollama' | 'unknown';
|
||||
model: string; // provider 实际模型名
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
currentModel: string;
|
||||
supportedModels: ModelInfo[];
|
||||
}
|
||||
|
||||
export const useModelStore = defineStore('modelStore', {
|
||||
state: (): State => ({
|
||||
// 从 localStorage 读取上次选择的模型,避免 SSR/Node 环境报错时使用短路
|
||||
currentModel: typeof window !== 'undefined' && localStorage.getItem('currentModel') ? String(localStorage.getItem('currentModel')) : 'gpt-4o-mini',
|
||||
supportedModels: [
|
||||
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'openai', model: 'gpt-4o-mini' },
|
||||
{ id: 'gpt-4o', label: 'GPT-4o', provider: 'openai', model: 'gpt-4o' },
|
||||
{ id: 'claude-3-5', label: 'Claude 3.5', provider: 'anthropic', model: 'claude-3-5-sonnet-latest' },
|
||||
{ id: 'gemini-flash', label: 'Gemini Flash', provider: 'google', model: 'gemini-1.5-flash' },
|
||||
{ id: 'ollama-llama3', label: 'Llama3 (本地)', provider: 'ollama', model: 'llama3' },
|
||||
{ id: 'deepseek-chat', label: 'DeepSeek Chat', provider: 'openai', model: 'deepseek-chat' }
|
||||
],
|
||||
}),
|
||||
actions: {
|
||||
setCurrentModel(id: string) {
|
||||
(this as any).currentModel = id;
|
||||
try { localStorage.setItem('currentModel', id); } catch (e) { /* ignore */ }
|
||||
},
|
||||
addModel(m: ModelInfo) {
|
||||
const self = this as any as State;
|
||||
if (!self.supportedModels.find((x: ModelInfo) => x.id === m.id)) {
|
||||
self.supportedModels.push(m);
|
||||
}
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
currentModelInfo: (s: State) => s.supportedModels.find((m: ModelInfo) => m.id === s.currentModel),
|
||||
}
|
||||
});
|
21
frontend/src/stores/themeStore.ts
Normal file
21
frontend/src/stores/themeStore.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export type Theme = 'dark' | 'light';
|
||||
const THEME_KEY = 'appTheme';
|
||||
|
||||
export const useThemeStore = defineStore('themeStore', {
|
||||
state: () => ({
|
||||
theme: (localStorage.getItem(THEME_KEY) as Theme) || 'dark'
|
||||
}),
|
||||
actions: {
|
||||
applyTheme() {
|
||||
document.documentElement.setAttribute('data-theme', this.theme);
|
||||
},
|
||||
setTheme(t: Theme) {
|
||||
this.theme = t;
|
||||
localStorage.setItem(THEME_KEY, t);
|
||||
this.applyTheme();
|
||||
},
|
||||
toggle() { this.setTheme(this.theme === 'dark' ? 'light' : 'dark'); }
|
||||
}
|
||||
});
|
37
frontend/src/styles/global.css
Normal file
37
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,37 @@
|
||||
:root {
|
||||
--bg-color:#141419;
|
||||
--text-color:#e5e7eb;
|
||||
--sidebar-bg:#1e1e24;
|
||||
--border-color:#333;
|
||||
--accent:#3b82f6;
|
||||
--accent-hover:#2563eb;
|
||||
--bubble-user-bg:#3a3f58;
|
||||
--bubble-ai-bg:#2d2f39;
|
||||
--bubble-text-color:#f2f2f7;
|
||||
--input-bg:#262730;
|
||||
--input-border:#333;
|
||||
--button-bg:var(--accent);
|
||||
--button-disabled-bg:#4b5563;
|
||||
--shadow-color:rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
[data-theme='light'] {
|
||||
--bg-color:#f5f7fa;
|
||||
--text-color:#1f2933;
|
||||
--sidebar-bg:#ffffff;
|
||||
--border-color:#e2e8f0;
|
||||
--bubble-user-bg:#2563eb10;
|
||||
--bubble-ai-bg:#f1f5f9;
|
||||
--bubble-text-color:#1f2933;
|
||||
--input-bg:#ffffff;
|
||||
--input-border:#cbd5e1;
|
||||
--button-bg:#2563eb;
|
||||
--button-disabled-bg:#94a3b8;
|
||||
--shadow-color:rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
html,body { margin:0; padding:0; height:100%; }
|
||||
body { background:var(--bg-color); color:var(--text-color); font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; overflow:hidden; }
|
||||
* { box-sizing:border-box; }
|
||||
::-webkit-scrollbar { width:8px; }
|
||||
::-webkit-scrollbar-thumb { background:var(--border-color); border-radius:4px; }
|
11
frontend/src/views/AboutView.vue
Normal file
11
frontend/src/views/AboutView.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h2>关于</h2>
|
||||
<p>这是一个可切换多家 AI 大模型的聊天前端示例。后续可扩展功能:对话上下文管理、会话列表、提示词模板、文件上传、工具调用、RAG 等。</p>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
<style scoped>
|
||||
.about { padding:24px; line-height:1.6; height:100%; overflow:auto; box-sizing:border-box; }
|
||||
</style>
|
175
frontend/src/views/ChatView.vue
Normal file
175
frontend/src/views/ChatView.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="chat-view">
|
||||
<div class="conv-meta" v-if="conversationId">当前会话: {{ conversationId }}</div>
|
||||
<div class="messages" ref="messagesEl">
|
||||
<template v-for="m in messages" :key="m.id">
|
||||
<div v-if="m.role==='system'" class="msg system">
|
||||
<div class="bubble system-bubble" v-html="formatMessage(m.content || '')" />
|
||||
</div>
|
||||
<div v-else-if="m.role==='user'" :class="['msg', 'user']">
|
||||
<div class="avatar">我</div>
|
||||
<div class="bubble" v-html="formatMessage(m.content || '')" />
|
||||
</div>
|
||||
<div v-else :class="['msg', 'assistant']">
|
||||
<div class="avatar">AI</div>
|
||||
<div class="bubble">
|
||||
<template v-for="(seg,i) in m.segments" :key="i">
|
||||
<div :class="['segment', seg.kind || 'answer']" v-html="formatMessage(seg.text)" />
|
||||
</template>
|
||||
<div v-if="m.loading" class="streaming-indicator">⌛ 正在生成...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<form class="input-bar" @submit.prevent="handleSend">
|
||||
<div class="input-text-wrap">
|
||||
<textarea v-model="input" class="in-textarea" placeholder="输入消息,Shift+Enter 换行" @keydown.enter.exact.prevent="handleSend" @keydown.shift.enter.stop rows="6"></textarea>
|
||||
<select v-model="selectedModel" class="model-select in-text bottom-left" aria-label="选择模型">
|
||||
<option v-for="m in modelStore.supportedModels" :key="m.id" :value="m.id">{{ m.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="send-btn" type="submit" :disabled="pending" title="发送">⬆︎</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, computed, watch } from 'vue';
|
||||
import { useModelStore } from '@/stores/modelStore';
|
||||
import { chatWithModel, StreamPayload } from '@/services/chatService';
|
||||
import { useConversationStore } from '@/stores/conversationStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
interface AssistantSegment { kind?: 'reasoning' | 'answer'; text: string; }
|
||||
interface ChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content?: string; segments?: AssistantSegment[]; loading?: boolean; transient?: boolean }
|
||||
|
||||
const modelStore = useModelStore();
|
||||
const convStore = useConversationStore();
|
||||
const authStore = useAuthStore();
|
||||
const authed = computed(()=>authStore.isAuthed);
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const input = ref('');
|
||||
const pending = ref(false);
|
||||
// 默认自动保存,移除显示的保存开关
|
||||
const conversationId = ref<string | null>(null);
|
||||
const messagesEl = ref<HTMLDivElement | null>(null);
|
||||
|
||||
function scrollBottom() {
|
||||
nextTick(() => { messagesEl.value?.scrollTo({ top: messagesEl.value.scrollHeight }); });
|
||||
}
|
||||
|
||||
function formatMessage(md: string) {
|
||||
// TODO: markdown 渲染,可后续替换为 marked
|
||||
return md.replace(/\n/g, '<br/>');
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = input.value.trim();
|
||||
if (!text || pending.value) return;
|
||||
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: text };
|
||||
messages.value.push(userMsg);
|
||||
const aiId = crypto.randomUUID();
|
||||
messages.value.push({ id: aiId, role: 'assistant', segments: [], loading: true });
|
||||
input.value = '';
|
||||
pending.value = true;
|
||||
scrollBottom();
|
||||
try {
|
||||
const baseMessages: { role: 'user' | 'assistant'; content: string; }[] = messages.value
|
||||
.filter(m => {
|
||||
// 排除系统或瞬时提示(不保存、不发送)
|
||||
if (m.role === 'system' || m.transient) return false;
|
||||
if (m.role === 'assistant') {
|
||||
// 排除当前正在生成且还没有 answer 内容的占位
|
||||
const answerText = (m.segments||[]).filter(s=>s.kind!=='reasoning').map(s=>s.text).join('');
|
||||
return !!answerText;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map(m => m.role === 'assistant'
|
||||
? { role: 'assistant', content: (m.segments||[]).filter(s=>s.kind!=='reasoning').map(s=>s.text).join('') }
|
||||
: { role: 'user', content: m.content || '' });
|
||||
let answerBuffer = '';
|
||||
for await (const chunk of chatWithModel({ modelId: modelStore.currentModel, messages: baseMessages, conversationId: conversationId.value || undefined, save: authed.value }, authStore.token || undefined)) {
|
||||
if ((chunk as any).conversationId && !conversationId.value) {
|
||||
const cid = String((chunk as any).conversationId);
|
||||
conversationId.value = cid;
|
||||
// load full conversation messages
|
||||
await convStore.loadConversation(cid);
|
||||
}
|
||||
const target = messages.value.find(m => m.id === aiId);
|
||||
if (!target) continue;
|
||||
if (chunk.kind === 'reasoning') {
|
||||
target.segments!.push({ kind: 'reasoning', text: chunk.text });
|
||||
} else if (chunk.kind === 'answer') {
|
||||
answerBuffer += chunk.text;
|
||||
const existingAnswer = target.segments!.find(s => s.kind === 'answer');
|
||||
if (existingAnswer) existingAnswer.text = answerBuffer; else target.segments!.push({ kind: 'answer', text: answerBuffer });
|
||||
} else if (chunk.text) {
|
||||
answerBuffer += chunk.text;
|
||||
const existingAnswer = target.segments!.find(s => s.kind === 'answer');
|
||||
if (existingAnswer) existingAnswer.text = answerBuffer; else target.segments!.push({ kind: 'answer', text: answerBuffer });
|
||||
}
|
||||
scrollBottom();
|
||||
}
|
||||
} catch (e:any) {
|
||||
const target = messages.value.find(m => m.id === aiId);
|
||||
if (target) target.segments = [{ kind: 'answer', text: '错误: ' + (e.message || e.toString()) }];
|
||||
} finally {
|
||||
const target = messages.value.find(m => m.id === aiId);
|
||||
if (target) target.loading = false;
|
||||
pending.value = false;
|
||||
scrollBottom();
|
||||
}
|
||||
}
|
||||
|
||||
function clear() { messages.value = []; conversationId.value = null; convStore.resetCurrent(); }
|
||||
|
||||
watch(() => convStore.currentId, (id) => {
|
||||
if (!id) { messages.value = []; conversationId.value = null; return; }
|
||||
// load messages from store
|
||||
messages.value = convStore.messages.map(m => m.role === 'user' ? { id: crypto.randomUUID(), role: 'user', content: m.content } : { id: crypto.randomUUID(), role: 'assistant', segments: [{ kind: 'answer', text: m.content }] });
|
||||
conversationId.value = id;
|
||||
scrollBottom();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
scrollBottom();
|
||||
if (convStore.currentId && convStore.messages.length) {
|
||||
messages.value = convStore.messages.map(m => m.role === 'user' ? { id: crypto.randomUUID(), role: 'user', content: m.content } : { id: crypto.randomUUID(), role: 'assistant', segments: [{ kind: 'answer', text: m.content }] });
|
||||
conversationId.value = convStore.currentId;
|
||||
}
|
||||
});
|
||||
|
||||
// 记忆模型选择:使用 computed setter/getter 调用 store action
|
||||
const selectedModel = computed({
|
||||
get: () => modelStore.currentModel,
|
||||
set: (v: string) => { modelStore.setCurrentModel(v); }
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-view { flex:1; display:flex; flex-direction:column; min-height:0; }
|
||||
.conv-meta { font-size:12px; opacity:.6; padding:4px 12px 0; }
|
||||
.messages { flex:1; min-height:0; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:12px; }
|
||||
.msg { display:flex; gap:12px; align-items:flex-start; }
|
||||
.msg.user { flex-direction:row-reverse; }
|
||||
.msg.user .bubble { background:var(--bubble-user-bg); }
|
||||
.msg.assistant { flex-direction:row; }
|
||||
.msg.assistant .bubble { background:var(--bubble-ai-bg); }
|
||||
.avatar { width:34px; height:34px; border-radius:8px; background:#444; display:flex; align-items:center; justify-content:center; color:#fff; font-size:12px; flex-shrink:0; }
|
||||
.bubble { padding:10px 12px; border-radius:12px; color:var(--bubble-text-color); font-size:14px; line-height:1.5; white-space:pre-wrap; max-width:780px; overflow-x:auto; display:flex; flex-direction:column; gap:8px; }
|
||||
.segment.reasoning { font-size:12px; opacity:.7; border-left:3px solid var(--button-bg); padding-left:6px; background:rgba(0,0,0,0.04); }
|
||||
/* answer segment uses default bubble styles */
|
||||
.streaming-indicator { margin-top:4px; font-size:12px; opacity:.6; }
|
||||
.input-bar { border-top:1px solid var(--border-color); padding:8px 12px; display:flex; gap:8px; align-items:center; background:var(--sidebar-bg); flex-shrink:0; }
|
||||
.input-text-wrap { position:relative; flex:1; }
|
||||
.model-select { background:var(--input-bg); color:var(--text-color); border:1px solid var(--input-border); padding:6px 8px; border-radius:8px; margin-right:8px; }
|
||||
.model-select.in-text { position:absolute; left:12px; top:8px; z-index:2; padding:6px 10px; }
|
||||
.model-select.in-text.bottom-left { top: auto; bottom:10px; left:12px; padding:6px 8px; font-size:12px; border-radius:6px; background:var(--input-bg); border:1px solid var(--input-border); }
|
||||
.in-textarea { width:100%; resize:vertical; min-height:120px; max-height:320px; background:var(--input-bg); color:var(--text-color); border:1px solid var(--input-border); padding:14px 14px 42px 14px; border-radius:8px; font-family: inherit; }
|
||||
.actions { display:flex; align-items:center; gap:8px; }
|
||||
.send-btn { width:44px; height:44px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; background:var(--button-bg); color:#fff; border:none; font-size:18px; cursor:pointer; }
|
||||
.send-btn[disabled] { background:var(--button-disabled-bg); cursor:not-allowed; }
|
||||
</style>
|
41
frontend/src/views/LoginView.vue
Normal file
41
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="login-view">
|
||||
<h2>登录 / 注册</h2>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<input v-model="email" placeholder="邮箱" type="email" required />
|
||||
<input v-model="password" placeholder="密码" type="password" required />
|
||||
<div class="actions">
|
||||
<button type="submit" :disabled="loading">{{ mode==='login' ? '登录' : '注册' }}</button>
|
||||
<button type="button" @click="toggle" :disabled="loading">切换为{{ mode==='login' ? '注册' : '登录' }}</button>
|
||||
</div>
|
||||
<div class="error" v-if="error">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const mode = ref<'login'|'register'>('login');
|
||||
const loading = computed(()=>auth.loading);
|
||||
const error = computed(()=>auth.error);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (mode.value==='login') await auth.login(email.value, password.value); else await auth.register(email.value, password.value);
|
||||
router.push('/chat');
|
||||
}
|
||||
function toggle() { mode.value = mode.value==='login' ? 'register' : 'login'; }
|
||||
</script>
|
||||
<style scoped>
|
||||
.login-view { padding:32px; max-width:360px; }
|
||||
form { display:flex; flex-direction:column; gap:12px; }
|
||||
input { background:var(--input-bg); border:1px solid var(--input-border); padding:8px 10px; border-radius:6px; color:var(--text-color); }
|
||||
.actions { display:flex; gap:8px; }
|
||||
button { background:var(--button-bg); color:#fff; border:none; padding:8px 14px; border-radius:6px; cursor:pointer; }
|
||||
.error { color:#ff5a5a; font-size:12px; }
|
||||
</style>
|
35
frontend/src/views/SettingsView.vue
Normal file
35
frontend/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="settings">
|
||||
<h2>设置</h2>
|
||||
<section>
|
||||
<h3>API Key & 端点</h3>
|
||||
<div v-for="prov in providers" :key="prov.id" class="provider-block">
|
||||
<h4>{{ prov.label }}</h4>
|
||||
<label>API Key: <input type="password" v-model="prov.apiKey" @change="save" /></label>
|
||||
<label v-if="prov.endpoint">Endpoint: <input type="text" v-model="prov.endpoint" @change="save" /></label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
|
||||
interface ProviderCfg { id: string; label: string; apiKey: string; endpoint?: string; }
|
||||
|
||||
const providers = reactive<ProviderCfg[]>([
|
||||
{ id: 'openai', label: 'OpenAI', apiKey: '' },
|
||||
{ id: 'anthropic', label: 'Anthropic', apiKey: '' },
|
||||
{ id: 'google', label: 'Google Gemini', apiKey: '' },
|
||||
{ id: 'ollama', label: 'Ollama 本地', apiKey: '', endpoint: 'http://localhost:11434' }
|
||||
]);
|
||||
|
||||
function save() {
|
||||
localStorage.setItem('providerKeys', JSON.stringify(providers));
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.settings { padding:24px; height:100%; overflow:auto; box-sizing:border-box; }
|
||||
.provider-block { border:1px solid var(--border-color); padding:12px 16px; margin-bottom:12px; border-radius:8px; background:var(--sidebar-bg); }
|
||||
label { display:block; margin:6px 0; font-size:14px; }
|
||||
input { background:var(--input-bg); border:1px solid var(--input-border); padding:6px 8px; color:var(--text-color); border-radius:6px; width:320px; }
|
||||
</style>
|
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"],
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
Reference in New Issue
Block a user