样式优化,markdown优化
This commit is contained in:
@@ -3,6 +3,7 @@ import { Schema, model, Document } from 'mongoose';
|
|||||||
export interface IUser extends Document {
|
export interface IUser extends Document {
|
||||||
email: string;
|
email: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
|
name?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -10,6 +11,7 @@ export interface IUser extends Document {
|
|||||||
const UserSchema = new Schema<IUser>({
|
const UserSchema = new Schema<IUser>({
|
||||||
email: { type: String, required: true, unique: true, index: true },
|
email: { type: String, required: true, unique: true, index: true },
|
||||||
passwordHash: { type: String, required: true }
|
passwordHash: { type: String, required: true }
|
||||||
|
, name: { type: String }
|
||||||
}, { timestamps: true });
|
}, { timestamps: true });
|
||||||
|
|
||||||
export const User = model<IUser>('User', UserSchema);
|
export const User = model<IUser>('User', UserSchema);
|
||||||
|
@@ -6,13 +6,13 @@ import { signToken } from '../middleware/auth.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.post('/register', async (req, res) => {
|
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' });
|
if (!email || !password) return res.status(400).json({ error: 'MISSING_FIELDS' });
|
||||||
const exist = await User.findOne({ email });
|
const exist = await User.findOne({ email });
|
||||||
if (exist) return res.status(409).json({ error: 'EMAIL_EXISTS' });
|
if (exist) return res.status(409).json({ error: 'EMAIL_EXISTS' });
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
const user = await User.create({ email, passwordHash });
|
const user = await User.create({ email, passwordHash, name });
|
||||||
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 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/login', async (req, res) => {
|
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' });
|
if (!user) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
||||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||||
if (!ok) return res.status(401).json({ error: 'INVALID_CREDENTIALS' });
|
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;
|
export default router;
|
||||||
|
@@ -15,6 +15,9 @@
|
|||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.29",
|
"vue": "^3.4.29",
|
||||||
"vue-router": "^4.3.0"
|
"vue-router": "^4.3.0"
|
||||||
|
,"marked": "^6.0.0",
|
||||||
|
"dompurify": "^3.0.0",
|
||||||
|
"highlight.js": "^11.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
|
@@ -9,12 +9,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<ConversationList @new="handleNewConv" />
|
<ConversationList @new="handleNewConv" />
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
<div class="avatar-area left-align">
|
<div class="avatar-area left-align" style="position:relative;">
|
||||||
<button class="avatar-btn" @click="authStore.isAuthed ? goSettings() : goLogin()">
|
<button class="avatar-btn" @click="authStore.isAuthed ? (showUserMenu = !showUserMenu) : goLogin()">
|
||||||
<img v-if="authStore.user" :src="avatarUrl(userEmail)" alt="avatar" />
|
<img v-if="authStore.user" :src="avatarUrl(userEmail)" alt="avatar" />
|
||||||
<div v-else class="avatar-fallback">{{ userInitial }}</div>
|
<div v-else class="avatar-fallback">{{ userInitial }}</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<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">
|
<div class="right-icons">
|
||||||
<button class="icon-btn" title="设置" @click="goSettings">⚙️</button>
|
<button class="icon-btn" title="设置" @click="goSettings">⚙️</button>
|
||||||
<button class="icon-btn" title="关于" @click="goAbout">ℹ️</button>
|
<button class="icon-btn" title="关于" @click="goAbout">ℹ️</button>
|
||||||
@@ -49,10 +61,26 @@ const themeLabel = computed(() => themeStore.theme === 'dark' ? '切换为浅色
|
|||||||
const menuOpen = ref(false);
|
const menuOpen = ref(false);
|
||||||
|
|
||||||
const userEmail = computed(() => authStore.user?.email || '');
|
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 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 goSettings() { router.push('/settings'); }
|
||||||
function goAbout() { router.push('/about'); }
|
function goAbout() { router.push('/about'); }
|
||||||
function goLogin() { router.push('/login'); }
|
function goLogin() { router.push('/login'); }
|
||||||
@@ -110,4 +138,10 @@ button:hover { filter: brightness(1.05); }
|
|||||||
button:active { filter: brightness(.95); }
|
button:active { filter: brightness(.95); }
|
||||||
.main { flex: 1; display: flex; flex-direction: column; }
|
.main { flex: 1; display: flex; flex-direction: column; }
|
||||||
.main { overflow:hidden; }
|
.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>
|
</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 App from './App.vue';
|
||||||
import router from './router';
|
import router from './router';
|
||||||
import './styles/global.css';
|
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';
|
import { useThemeStore } from './stores/themeStore';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
interface UserInfo { id: string; email: string; }
|
interface UserInfo { userId: string; userName: string; email: string; }
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
@@ -13,7 +13,7 @@ interface AuthState {
|
|||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: (): AuthState => ({
|
state: (): AuthState => ({
|
||||||
token: localStorage.getItem('token'),
|
token: localStorage.getItem('token'),
|
||||||
user: null,
|
user: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
@@ -26,7 +26,7 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
try {
|
try {
|
||||||
const { data } = await axios.post('/api/auth/login', { email, password });
|
const { data } = await axios.post('/api/auth/login', { email, password });
|
||||||
this.token = data.token; localStorage.setItem('token', data.token);
|
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) {
|
} catch (e:any) {
|
||||||
this.error = e.response?.data?.error || e.message;
|
this.error = e.response?.data?.error || e.message;
|
||||||
throw e;
|
throw e;
|
||||||
@@ -34,12 +34,14 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async register(email: string, password: string) {
|
async register(email: string, password: string, userName?: string) {
|
||||||
this.loading = true; this.error = null;
|
this.loading = true; this.error = null;
|
||||||
try {
|
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.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) {
|
} catch (e:any) {
|
||||||
this.error = e.response?.data?.error || e.message;
|
this.error = e.response?.data?.error || e.message;
|
||||||
throw e;
|
throw e;
|
||||||
|
@@ -45,6 +45,17 @@ export const useConversationStore = defineStore('conversation', {
|
|||||||
this.messages.push({ role: 'system', content: notify, transient: true });
|
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); },
|
pushMessage(m: ConversationMessage) { this.messages.push(m); },
|
||||||
patchLastAssistant(content: string) {
|
patchLastAssistant(content: string) {
|
||||||
for (let i = this.messages.length - 1; i >=0; i--) {
|
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="conv-meta" v-if="conversationId">当前会话: {{ conversationId }}</div>
|
||||||
<div class="messages" ref="messagesEl">
|
<div class="messages" ref="messagesEl">
|
||||||
<template v-for="m in messages" :key="m.id">
|
<template v-for="m in messages" :key="m.id">
|
||||||
<div v-if="m.role==='system'" class="msg system">
|
<div v-if="m.role==='system'" class="msg system">
|
||||||
<div class="bubble system-bubble" v-html="formatMessage(m.content || '')" />
|
<div class="bubble system-bubble" v-html="m.html || ''"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="m.role==='user'" :class="['msg', 'user']">
|
<div v-else-if="m.role==='user'" :class="['msg', 'user']">
|
||||||
<div class="avatar">我</div>
|
<div class="avatar">我</div>
|
||||||
<div class="bubble" v-html="formatMessage(m.content || '')" />
|
<div class="bubble" v-html="m.html || ''"></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else :class="['msg', 'assistant']">
|
<div v-else :class="['msg', 'assistant']">
|
||||||
<div class="avatar">AI</div>
|
<div class="avatar">AI</div>
|
||||||
<div class="bubble">
|
<div class="bubble">
|
||||||
<template v-for="(seg,i) in m.segments" :key="i">
|
<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>
|
</template>
|
||||||
<div v-if="m.loading" class="streaming-indicator">⌛ 正在生成...</div>
|
<div v-if="m.loading" class="streaming-indicator">⌛ 正在生成...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,9 +41,11 @@ import { useModelStore } from '@/stores/modelStore';
|
|||||||
import { chatWithModel, StreamPayload } from '@/services/chatService';
|
import { chatWithModel, StreamPayload } from '@/services/chatService';
|
||||||
import { useConversationStore } from '@/stores/conversationStore';
|
import { useConversationStore } from '@/stores/conversationStore';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import MarkdownRenderer from '@/components/MarkdownRenderer.vue';
|
||||||
|
import { renderMarkdown } from '@/utils/markdown';
|
||||||
|
|
||||||
interface AssistantSegment { kind?: 'reasoning' | 'answer'; text: string; }
|
interface AssistantSegment { kind?: 'reasoning' | 'answer'; text: string; html?: string }
|
||||||
interface ChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content?: string; segments?: AssistantSegment[]; loading?: boolean; transient?: boolean }
|
interface ChatMessage { id: string; role: 'user' | 'assistant' | 'system'; content?: string; html?: string; segments?: AssistantSegment[]; loading?: boolean; transient?: boolean }
|
||||||
|
|
||||||
const modelStore = useModelStore();
|
const modelStore = useModelStore();
|
||||||
const convStore = useConversationStore();
|
const convStore = useConversationStore();
|
||||||
@@ -61,17 +63,17 @@ function scrollBottom() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMessage(md: string) {
|
function formatMessage(md: string) {
|
||||||
// TODO: markdown 渲染,可后续替换为 marked
|
// 保留给老逻辑的兼容函数,优先使用 MarkdownRenderer 组件渲染
|
||||||
return md.replace(/\n/g, '<br/>');
|
return renderMarkdown(md || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = input.value.trim();
|
const text = input.value.trim();
|
||||||
if (!text || pending.value) return;
|
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);
|
messages.value.push(userMsg);
|
||||||
const aiId = crypto.randomUUID();
|
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 = '';
|
input.value = '';
|
||||||
pending.value = true;
|
pending.value = true;
|
||||||
scrollBottom();
|
scrollBottom();
|
||||||
@@ -101,15 +103,29 @@ async function handleSend() {
|
|||||||
const target = messages.value.find(m => m.id === aiId);
|
const target = messages.value.find(m => m.id === aiId);
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
if (chunk.kind === 'reasoning') {
|
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') {
|
} else if (chunk.kind === 'answer') {
|
||||||
answerBuffer += chunk.text;
|
answerBuffer += chunk.text;
|
||||||
const existingAnswer = target.segments!.find(s => s.kind === 'answer');
|
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) {
|
} else if (chunk.text) {
|
||||||
answerBuffer += chunk.text;
|
answerBuffer += chunk.text;
|
||||||
const existingAnswer = target.segments!.find(s => s.kind === 'answer');
|
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();
|
scrollBottom();
|
||||||
}
|
}
|
||||||
@@ -128,8 +144,11 @@ function clear() { messages.value = []; conversationId.value = null; convStore.r
|
|||||||
|
|
||||||
watch(() => convStore.currentId, (id) => {
|
watch(() => convStore.currentId, (id) => {
|
||||||
if (!id) { messages.value = []; conversationId.value = null; return; }
|
if (!id) { messages.value = []; conversationId.value = null; return; }
|
||||||
// load messages from store
|
// load messages from store and precompute html
|
||||||
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 = id;
|
conversationId.value = id;
|
||||||
scrollBottom();
|
scrollBottom();
|
||||||
});
|
});
|
||||||
@@ -137,7 +156,10 @@ watch(() => convStore.currentId, (id) => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollBottom();
|
scrollBottom();
|
||||||
if (convStore.currentId && convStore.messages.length) {
|
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;
|
conversationId.value = convStore.currentId;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@@ -4,6 +4,7 @@
|
|||||||
<form @submit.prevent="handleSubmit">
|
<form @submit.prevent="handleSubmit">
|
||||||
<input v-model="email" placeholder="邮箱" type="email" required />
|
<input v-model="email" placeholder="邮箱" type="email" required />
|
||||||
<input v-model="password" placeholder="密码" type="password" required />
|
<input v-model="password" placeholder="密码" type="password" required />
|
||||||
|
<input v-if="mode==='register'" v-model="userName" placeholder="用户名(注册)" type="text" />
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" :disabled="loading">{{ mode==='login' ? '登录' : '注册' }}</button>
|
<button type="submit" :disabled="loading">{{ mode==='login' ? '登录' : '注册' }}</button>
|
||||||
<button type="button" @click="toggle" :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 mode = ref<'login'|'register'>('login');
|
||||||
const loading = computed(()=>auth.loading);
|
const loading = computed(()=>auth.loading);
|
||||||
const error = computed(()=>auth.error);
|
const error = computed(()=>auth.error);
|
||||||
|
const userName = ref('');
|
||||||
|
|
||||||
async function handleSubmit() {
|
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');
|
router.push('/chat');
|
||||||
}
|
}
|
||||||
function toggle() { mode.value = mode.value==='login' ? 'register' : 'login'; }
|
function toggle() { mode.value = mode.value==='login' ? 'register' : 'login'; }
|
||||||
|
Reference in New Issue
Block a user