system commit

This commit is contained in:
2025-08-22 14:22:43 +08:00
parent bf6f910db1
commit a1537e3f9f
36 changed files with 1311 additions and 0 deletions

9
backend/.env.example Normal file
View 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
View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

31
backend/README.md Normal file
View 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 与 DeepSeekDeepSeek 通过 model 名包含 "deepseek" 自动匹配,使用 OpenAI 协议 /v1/chat/completions。可继续在 `llmService.ts` 中扩展更多 provider 逻辑。

30
backend/package.json Normal file
View 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
View 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();

View 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' });
}

View 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);

View 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);

View 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
View 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;

View 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];
}

View 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
View 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
View File

@@ -0,0 +1,2 @@
VITE_API_BASE=/api
# 可自定义后端地址: VITE_API_BASE=http://localhost:3000/api

21
frontend/.eslintrc.cjs Normal file
View 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
View 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
View 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
View 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"
}
}

View 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
View 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>

View 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
View 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
View 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;

View 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
View File

@@ -0,0 +1,5 @@
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View 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'); }
}
});

View 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; }
}
}
});

View 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),
}
});

View 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'); }
}
});

View 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; }

View 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>

View 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>

View 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>

View 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
View 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
View 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
}
},
},
});