样式优化,markdown优化
This commit is contained in:
@@ -3,6 +3,7 @@ import { Schema, model, Document } from 'mongoose';
|
||||
export interface IUser extends Document {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
name?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -10,6 +11,7 @@ export interface IUser extends Document {
|
||||
const UserSchema = new Schema<IUser>({
|
||||
email: { type: String, required: true, unique: true, index: true },
|
||||
passwordHash: { type: String, required: true }
|
||||
, name: { type: String }
|
||||
}, { timestamps: true });
|
||||
|
||||
export const User = model<IUser>('User', UserSchema);
|
||||
|
@@ -6,13 +6,13 @@ import { signToken } from '../middleware/auth.js';
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
const { email, password, name } = 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 } });
|
||||
const user = await User.create({ email, passwordHash, name });
|
||||
return res.json({ token: signToken(user.id), user: { id: user.id, email: user.email, name: user.name } });
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
@@ -22,7 +22,7 @@ router.post('/login', async (req, res) => {
|
||||
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 } });
|
||||
return res.json({ token: signToken(user.id), user: { id: user.id, email: user.email, name: user.name } });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
@@ -15,6 +15,9 @@
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.29",
|
||||
"vue-router": "^4.3.0"
|
||||
,"marked": "^6.0.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"highlight.js": "^11.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
|
@@ -9,12 +9,24 @@
|
||||
</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="avatar-area left-align" style="position:relative;">
|
||||
<button class="avatar-btn" @click="authStore.isAuthed ? (showUserMenu = !showUserMenu) : goLogin()">
|
||||
<img v-if="authStore.user" :src="avatarUrl(userEmail)" alt="avatar" />
|
||||
<div v-else class="avatar-fallback">{{ userInitial }}</div>
|
||||
</button>
|
||||
<div v-if="showUserMenu" class="user-menu">
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ authStore.user?.userName || '访客' }}</div>
|
||||
<div class="user-id" v-if="authStore.user">ID: {{ authStore.user.userId }}</div>
|
||||
<div class="user-status">{{ authStore.isAuthed ? '已登录' : '未登录' }}</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button v-if="authStore.isAuthed" @click="logout">登出</button>
|
||||
<button v-else @click="goLogin">登录</button>
|
||||
<button @click="goSettings">设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-icons">
|
||||
<button class="icon-btn" title="设置" @click="goSettings">⚙️</button>
|
||||
<button class="icon-btn" title="关于" @click="goAbout">ℹ️</button>
|
||||
@@ -49,10 +61,26 @@ 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] : '访');
|
||||
const userInitial = computed(() => authStore.user ? (authStore.user.userName || authStore.user.email)[0] : '访');
|
||||
const showUserMenu = ref(false);
|
||||
|
||||
function logout() { authStore.logout(); showUserMenu.value = false; router.push('/'); }
|
||||
|
||||
function toggleTheme() { themeStore.toggle(); }
|
||||
function handleNewConv() { convStore.createNewConversation('已创建新会话(此消息不会保存)'); router.push('/chat'); }
|
||||
async function handleNewConv() {
|
||||
if (authStore.isAuthed) {
|
||||
// create empty conversation on server and navigate
|
||||
const conv = await convStore.createConversation(modelStore.currentModel, [], '新会话');
|
||||
if (conv && conv._id) {
|
||||
router.push('/chat');
|
||||
// let ChatView load via store currentId watcher
|
||||
return;
|
||||
}
|
||||
}
|
||||
// fallback: local transient
|
||||
convStore.createNewConversation('已创建新会话(此消息不会保存)');
|
||||
router.push('/chat');
|
||||
}
|
||||
function goSettings() { router.push('/settings'); }
|
||||
function goAbout() { router.push('/about'); }
|
||||
function goLogin() { router.push('/login'); }
|
||||
@@ -110,4 +138,10 @@ button:hover { filter: brightness(1.05); }
|
||||
button:active { filter: brightness(.95); }
|
||||
.main { flex: 1; display: flex; flex-direction: column; }
|
||||
.main { overflow:hidden; }
|
||||
/* user menu positioning */
|
||||
.user-menu { position:absolute; bottom:56px; left:0; background:var(--sidebar-bg); border:1px solid var(--input-border); padding:8px; border-radius:8px; min-width:160px; box-shadow:0 6px 18px rgba(0,0,0,0.12); }
|
||||
.user-menu .user-name { font-weight:600; }
|
||||
.user-menu .user-id { font-size:12px; opacity:.7; }
|
||||
.user-menu .user-actions { display:flex; gap:8px; margin-top:8px; }
|
||||
.user-menu button { padding:6px 8px; border-radius:6px; }
|
||||
</style>
|
||||
|
17
frontend/src/components/MarkdownRenderer.vue
Normal file
17
frontend/src/components/MarkdownRenderer.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="markdown-renderer" v-html="html"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { renderMarkdown } from '@/utils/markdown';
|
||||
|
||||
const props = defineProps<{ content?: string }>();
|
||||
const html = computed(() => renderMarkdown(props.content ?? ''));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-renderer { color: var(--text-color); line-height:1.6; word-break:break-word; }
|
||||
.markdown-renderer pre { background:var(--code-bg,#0b1220); color:var(--code-color,#e6edf3); padding:12px; border-radius:8px; overflow:auto; }
|
||||
.markdown-renderer code { background:rgba(0,0,0,0.04); padding:2px 6px; border-radius:4px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Noto Mono", monospace; }
|
||||
</style>
|
@@ -3,6 +3,8 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './styles/global.css';
|
||||
// optional: highlight.js style for code blocks in markdown
|
||||
import 'highlight.js/styles/github-dark.css';
|
||||
import { useThemeStore } from './stores/themeStore';
|
||||
|
||||
const app = createApp(App);
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
|
||||
interface UserInfo { id: string; email: string; }
|
||||
interface UserInfo { userId: string; userName: string; email: string; }
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
@@ -13,7 +13,7 @@ interface AuthState {
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
token: localStorage.getItem('token'),
|
||||
user: null,
|
||||
user: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
@@ -26,7 +26,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
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 };
|
||||
this.user = data.user ? { userId: data.user.id, userName: data.user.name || data.user.email.split('@')[0], email: data.user.email } : { userId: 'me', userName: email.split('@')[0], email };
|
||||
} catch (e:any) {
|
||||
this.error = e.response?.data?.error || e.message;
|
||||
throw e;
|
||||
@@ -34,12 +34,14 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async register(email: string, password: string) {
|
||||
async register(email: string, password: string, userName?: string) {
|
||||
this.loading = true; this.error = null;
|
||||
try {
|
||||
const { data } = await axios.post('/api/auth/register', { email, password });
|
||||
const payload: any = { email, password };
|
||||
if (userName) payload.name = userName;
|
||||
const { data } = await axios.post('/api/auth/register', payload);
|
||||
this.token = data.token; localStorage.setItem('token', data.token);
|
||||
this.user = data.user ? { id: data.user.id, email: data.user.email } : { id: 'me', email };
|
||||
this.user = data.user ? { userId: data.user.id, userName: data.user.name || data.user.email.split('@')[0], email: data.user.email } : { userId: 'me', userName: email.split('@')[0], email };
|
||||
} catch (e:any) {
|
||||
this.error = e.response?.data?.error || e.message;
|
||||
throw e;
|
||||
|
@@ -45,6 +45,17 @@ export const useConversationStore = defineStore('conversation', {
|
||||
this.messages.push({ role: 'system', content: notify, transient: true });
|
||||
}
|
||||
},
|
||||
async createConversation(modelId: string, messages: any[] = [], title?: string) {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.token) return null;
|
||||
try {
|
||||
const { data } = await axios.post('/api/conversations', { modelId, messages, title }, { headers: { Authorization: 'Bearer ' + auth.token } });
|
||||
// prepend to list
|
||||
this.list.unshift(data);
|
||||
this.currentId = data._id;
|
||||
return data;
|
||||
} catch (e:any) { this.error = e.response?.data?.error || e.message; return null; }
|
||||
},
|
||||
pushMessage(m: ConversationMessage) { this.messages.push(m); },
|
||||
patchLastAssistant(content: string) {
|
||||
for (let i = this.messages.length - 1; i >=0; i--) {
|
||||
|
28
frontend/src/utils/markdown.ts
Normal file
28
frontend/src/utils/markdown.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
// Configure marked to use highlight.js for code blocks
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
smartLists: true,
|
||||
smartypants: false,
|
||||
highlight: (code: string, lang?: string) => {
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
} catch (e) {
|
||||
return code;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export function renderMarkdown(markdown = ''): string {
|
||||
const raw = String(markdown || '');
|
||||
const html = marked.parse(raw);
|
||||
// DOMPurify.sanitize 在浏览器环境下工作
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
@@ -3,18 +3,18 @@
|
||||
<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-if="m.role==='system'" class="msg system">
|
||||
<div class="bubble system-bubble" v-html="m.html || ''"></div>
|
||||
</div>
|
||||
<div v-else-if="m.role==='user'" :class="['msg', 'user']">
|
||||
<div class="avatar">我</div>
|
||||
<div class="bubble" v-html="formatMessage(m.content || '')" />
|
||||
<div class="bubble" v-html="m.html || ''"></div>
|
||||
</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)" />
|
||||
<div :class="['segment', seg.kind || 'answer']" v-html="seg.html || ''"></div>
|
||||
</template>
|
||||
<div v-if="m.loading" class="streaming-indicator">⌛ 正在生成...</div>
|
||||
</div>
|
||||
@@ -41,9 +41,11 @@ import { useModelStore } from '@/stores/modelStore';
|
||||
import { chatWithModel, StreamPayload } from '@/services/chatService';
|
||||
import { useConversationStore } from '@/stores/conversationStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import MarkdownRenderer from '@/components/MarkdownRenderer.vue';
|
||||
import { renderMarkdown } from '@/utils/markdown';
|
||||
|
||||
interface AssistantSegment { kind?: 'reasoning' | 'answer'; text: string; }
|
||||
interface ChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content?: string; segments?: AssistantSegment[]; loading?: boolean; transient?: boolean }
|
||||
interface AssistantSegment { kind?: 'reasoning' | 'answer'; text: string; html?: string }
|
||||
interface ChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content?: string; html?: string; segments?: AssistantSegment[]; loading?: boolean; transient?: boolean }
|
||||
|
||||
const modelStore = useModelStore();
|
||||
const convStore = useConversationStore();
|
||||
@@ -61,17 +63,17 @@ function scrollBottom() {
|
||||
}
|
||||
|
||||
function formatMessage(md: string) {
|
||||
// TODO: markdown 渲染,可后续替换为 marked
|
||||
return md.replace(/\n/g, '<br/>');
|
||||
// 保留给老逻辑的兼容函数,优先使用 MarkdownRenderer 组件渲染
|
||||
return renderMarkdown(md || '');
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = input.value.trim();
|
||||
if (!text || pending.value) return;
|
||||
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: text };
|
||||
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', content: text, html: renderMarkdown(text) };
|
||||
messages.value.push(userMsg);
|
||||
const aiId = crypto.randomUUID();
|
||||
messages.value.push({ id: aiId, role: 'assistant', segments: [], loading: true });
|
||||
messages.value.push({ id: aiId, role: 'assistant', segments: [], loading: true, html: '' });
|
||||
input.value = '';
|
||||
pending.value = true;
|
||||
scrollBottom();
|
||||
@@ -101,15 +103,29 @@ async function handleSend() {
|
||||
const target = messages.value.find(m => m.id === aiId);
|
||||
if (!target) continue;
|
||||
if (chunk.kind === 'reasoning') {
|
||||
target.segments!.push({ kind: 'reasoning', text: chunk.text });
|
||||
const seg = { kind: 'reasoning', text: chunk.text, html: renderMarkdown(chunk.text) } as AssistantSegment;
|
||||
target.segments!.push(seg);
|
||||
} 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 });
|
||||
if (existingAnswer) {
|
||||
existingAnswer.text = answerBuffer;
|
||||
existingAnswer.html = renderMarkdown(answerBuffer);
|
||||
} else {
|
||||
target.segments!.push({ kind: 'answer', text: answerBuffer, html: renderMarkdown(answerBuffer) });
|
||||
}
|
||||
// also update message-level html cache
|
||||
target.html = target.segments!.map(s => s.html || '').join('');
|
||||
} 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 });
|
||||
if (existingAnswer) {
|
||||
existingAnswer.text = answerBuffer;
|
||||
existingAnswer.html = renderMarkdown(answerBuffer);
|
||||
} else {
|
||||
target.segments!.push({ kind: 'answer', text: answerBuffer, html: renderMarkdown(answerBuffer) });
|
||||
}
|
||||
target.html = target.segments!.map(s => s.html || '').join('');
|
||||
}
|
||||
scrollBottom();
|
||||
}
|
||||
@@ -128,8 +144,11 @@ function clear() { messages.value = []; conversationId.value = null; convStore.r
|
||||
|
||||
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 }] });
|
||||
// load messages from store and precompute html
|
||||
messages.value = convStore.messages.map(m => m.role === 'user'
|
||||
? { id: crypto.randomUUID(), role: 'user', content: m.content, html: renderMarkdown(m.content) }
|
||||
: { id: crypto.randomUUID(), role: 'assistant', segments: [{ kind: 'answer', text: m.content, html: renderMarkdown(m.content) }] }
|
||||
);
|
||||
conversationId.value = id;
|
||||
scrollBottom();
|
||||
});
|
||||
@@ -137,7 +156,10 @@ watch(() => convStore.currentId, (id) => {
|
||||
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 }] });
|
||||
messages.value = convStore.messages.map(m => m.role === 'user'
|
||||
? { id: crypto.randomUUID(), role: 'user', content: m.content, html: renderMarkdown(m.content) }
|
||||
: { id: crypto.randomUUID(), role: 'assistant', segments: [{ kind: 'answer', text: m.content, html: renderMarkdown(m.content) }] }
|
||||
);
|
||||
conversationId.value = convStore.currentId;
|
||||
}
|
||||
});
|
||||
|
@@ -4,6 +4,7 @@
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<input v-model="email" placeholder="邮箱" type="email" required />
|
||||
<input v-model="password" placeholder="密码" type="password" required />
|
||||
<input v-if="mode==='register'" v-model="userName" placeholder="用户名(注册)" type="text" />
|
||||
<div class="actions">
|
||||
<button type="submit" :disabled="loading">{{ mode==='login' ? '登录' : '注册' }}</button>
|
||||
<button type="button" @click="toggle" :disabled="loading">切换为{{ mode==='login' ? '注册' : '登录' }}</button>
|
||||
@@ -24,9 +25,14 @@ const password = ref('');
|
||||
const mode = ref<'login'|'register'>('login');
|
||||
const loading = computed(()=>auth.loading);
|
||||
const error = computed(()=>auth.error);
|
||||
const userName = ref('');
|
||||
|
||||
async function handleSubmit() {
|
||||
if (mode.value==='login') await auth.login(email.value, password.value); else await auth.register(email.value, password.value);
|
||||
if (mode.value==='login') {
|
||||
await auth.login(email.value, password.value);
|
||||
} else {
|
||||
await auth.register(email.value, password.value, userName.value);
|
||||
}
|
||||
router.push('/chat');
|
||||
}
|
||||
function toggle() { mode.value = mode.value==='login' ? 'register' : 'login'; }
|
||||
|
Reference in New Issue
Block a user