feat: 增加样式渲染

This commit is contained in:
ViperEkura 2026-04-15 19:47:58 +08:00
parent 96dcb5d20e
commit 7a6ab26379
7 changed files with 152 additions and 83 deletions

View File

@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"axios": "^1.15.0", "axios": "^1.15.0",
"highlight.js": "^11.11.1",
"katex": "^0.16.11", "katex": "^0.16.11",
"marked": "^15.0.0", "marked": "^15.0.0",
"marked-highlight": "^2.2.1", "marked-highlight": "^2.2.1",
@ -67,6 +68,29 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@ -881,6 +905,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/hookable": { "node_modules/hookable": {
"version": "5.5.3", "version": "5.5.3",
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
@ -1190,7 +1223,6 @@
"resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz", "resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
@ -1279,7 +1311,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -1453,7 +1484,6 @@
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
@ -1531,7 +1561,6 @@
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.32.tgz", "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.32.tgz",
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.32", "@vue/compiler-dom": "3.5.32",
"@vue/compiler-sfc": "3.5.32", "@vue/compiler-sfc": "3.5.32",

View File

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.15.0", "axios": "^1.15.0",
"highlight.js": "^11.11.1",
"katex": "^0.16.11", "katex": "^0.16.11",
"marked": "^15.0.0", "marked": "^15.0.0",
"marked-highlight": "^2.2.1", "marked-highlight": "^2.2.1",

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="message-bubble" :class="[role]"> <div class="message-bubble" :class="[message.role]">
<div v-if="role === 'user'" class="avatar">user</div> <div v-if="message.role === 'user'" class="avatar">user</div>
<div v-else class="avatar">Luxx</div> <div v-else class="avatar">Luxx</div>
<div class="message-container"> <div class="message-container">
<!-- File attachments list --> <!-- File attachments list -->
<div v-if="attachments && attachments.length > 0" class="attachments-list"> <div v-if="message.attachments && message.attachments.length > 0" class="attachments-list">
<div v-for="(file, index) in attachments" :key="index" class="attachment-item"> <div v-for="(file, index) in message.attachments" :key="index" class="attachment-item">
<span class="attachment-icon">{{ file.extension }}</span> <span class="attachment-icon">{{ file.extension }}</span>
<span class="attachment-name">{{ file.name }}</span> <span class="attachment-name">{{ file.name }}</span>
</div> </div>
@ -13,30 +13,32 @@
<div ref="messageRef" class="message-body"> <div ref="messageRef" class="message-body">
<!-- Primary rendering path: processSteps contains all ordered steps --> <!-- Primary rendering path: processSteps contains all ordered steps -->
<ProcessBlock <ProcessBlock
v-if="processSteps && processSteps.length > 0" v-if="message.process_steps && message.process_steps.length > 0"
:process-steps="processSteps" :process-steps="message.process_steps"
/> />
<!-- Fallback path: old messages without processSteps in DB --> <!-- Fallback path: old messages without processSteps in DB -->
<template v-else> <template v-else>
<ProcessBlock <ProcessBlock
v-if="toolCalls && toolCalls.length > 0" v-if="message.tool_calls && message.tool_calls.length > 0"
:tool-calls="toolCalls" :tool-calls="message.tool_calls"
/> />
<div class="md-content message-content" v-html="renderedContent"></div> <div class="md-content message-content" v-html="renderedContent"></div>
</template> </template>
</div> </div>
<div class="message-footer"> <div class="message-footer">
<span class="token-info" v-if="usage"> <span class="message-time">{{ formatTime(message.created_at) }}</span>
<span class="token-item" v-if="usage.prompt_tokens">输入: {{ usage.prompt_tokens }}</span> <template v-if="message.role === 'assistant' && message.usage">
<span class="token-item" v-if="usage.completion_tokens">输出: {{ usage.completion_tokens }}</span> <span class="token-item" v-if="message.usage.prompt">{{ formatNumber(message.usage.prompt) }} in</span>
<span class="token-item total" v-if="usage.total_tokens">总计: {{ usage.total_tokens }}</span> <span class="token-item" v-if="message.usage.completion">{{ formatNumber(message.usage.completion) }} out</span>
</span> <span class="token-item" v-if="message.usage.total">{{ formatNumber(message.usage.total) }} total</span>
<span class="token-count" v-else-if="tokenCount">{{ tokenCount }} tokens</span> </template>
<span class="message-time">{{ formatTime(createdAt) }}</span> <button v-if="message.role === 'assistant'" class="ghost-btn success" @click="$emit('regenerate', message.id)" title="重新生成">
<button v-if="role === 'assistant'" class="ghost-btn accent" @click="copyContent" title="复制"> <span v-html="regenerateIcon"></span>
</button>
<button v-if="message.role === 'assistant'" class="ghost-btn accent" @click="copyContent" title="复制">
<span v-html="copyIcon"></span> <span v-html="copyIcon"></span>
</button> </button>
<button v-if="deletable" class="ghost-btn danger" @click="$emit('delete')" title="删除"> <button v-if="deletable" class="ghost-btn danger" @click="$emit('delete', message.id)" title="删除">
<span v-html="trashIcon"></span> <span v-html="trashIcon"></span>
</button> </button>
</div> </div>
@ -47,27 +49,22 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { renderMarkdown } from '../utils/markdown.js' import { renderMarkdown } from '../utils/markdown.js'
import { formatNumber } from '../utils/useFormatters.js'
import ProcessBlock from './ProcessBlock.vue' import ProcessBlock from './ProcessBlock.vue'
const props = defineProps({ const props = defineProps({
role: { type: String, required: true }, message: { type: Object, required: true },
text: { type: String, default: '' },
toolCalls: { type: Array, default: () => [] },
processSteps: { type: Array, default: () => [] },
tokenCount: { type: Number, default: 0 },
usage: { type: Object, default: null },
createdAt: { type: String, default: '' },
deletable: { type: Boolean, default: false }, deletable: { type: Boolean, default: false },
attachments: { type: Array, default: () => [] },
}) })
defineEmits(['delete']) defineEmits(['delete', 'regenerate'])
const messageRef = ref(null) const messageRef = ref(null)
const renderedContent = computed(() => { const renderedContent = computed(() => {
if (!props.text) return '' const text = props.message.content || props.message.text || ''
return renderMarkdown(props.text) if (!text) return ''
return renderMarkdown(text)
}) })
function formatTime(time) { function formatTime(time) {
@ -78,9 +75,9 @@ function formatTime(time) {
} }
function copyContent() { function copyContent() {
let text = props.text || '' let text = props.message.content || props.message.text || ''
if (props.processSteps && props.processSteps.length > 0) { if (props.message.process_steps && props.message.process_steps.length > 0) {
const parts = props.processSteps const parts = props.message.process_steps
.filter(s => s && s.type === 'text') .filter(s => s && s.type === 'text')
.map(s => s.content) .map(s => s.content)
if (parts.length > 0) text = parts.join('\n\n') if (parts.length > 0) text = parts.join('\n\n')
@ -92,6 +89,8 @@ function copyContent() {
const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>` const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`
const trashIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>` const trashIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>`
const regenerateIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"></path><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>`
</script> </script>
<style scoped> <style scoped>
@ -135,30 +134,6 @@ const trashIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" s
gap: 8px; gap: 8px;
padding: 6px 0 0; padding: 6px 0 0;
font-size: 12px; font-size: 12px;
}
.token-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-tertiary);
}
.token-item {
padding: 2px 6px;
background: var(--bg-code);
border-radius: 4px;
}
.token-item.total {
font-weight: 600;
color: var(--accent-primary);
}
.token-count,
.message-time {
font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
</style> </style>

View File

@ -1,5 +1,7 @@
import { marked } from 'marked' import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import katex from 'katex' import katex from 'katex'
import hljs from 'highlight.js'
function renderMath(text, displayMode) { function renderMath(text, displayMode) {
try { try {
@ -51,6 +53,15 @@ const blockMathExtension = {
}, },
} }
// 配置 marked 支持代码高亮
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
return hljs.highlight(code, { language }).value
}
}))
marked.use({ marked.use({
gfm: true, gfm: true,
breaks: true, breaks: true,

View File

@ -185,6 +185,47 @@ export function useConversations() {
// 调用 API 保存 // 调用 API 保存
await conversationsAPI.update(c.id, { title: newTitle }) await conversationsAPI.update(c.id, { title: newTitle })
} }
// 删除消息
const deleteMessage = async (msgId) => {
if (!selectedConv.value) return
try {
await messagesAPI.delete(msgId)
// 从本地列表中移除
convMessages.value = convMessages.value.filter(m => m.id !== msgId)
} catch (e) {
console.error('删除消息失败:', e)
throw e
}
}
// 重新生成消息(删除 assistant 消息并重新发送用户消息)
const regenerateMessage = async (msgId) => {
if (!selectedConv.value || sending.value) return
// 找到要重新生成的消息
const msgIndex = convMessages.value.findIndex(m => m.id === msgId)
if (msgIndex === -1) return
// 找到对应的用户消息assistant 消息的前一条)
const userMsgIndex = msgIndex - 1
if (userMsgIndex < 0 || convMessages.value[userMsgIndex].role !== 'user') return
const userMsg = convMessages.value[userMsgIndex]
// 删除 assistant 消息
convMessages.value = convMessages.value.filter(m => m.id !== msgId)
// 调用 API 删除 assistant 消息
try {
await messagesAPI.delete(selectedConv.value.id, msgId)
} catch (e) {
console.error('删除消息失败:', e)
}
// 重新发送用户消息
await sendMessage(userMsg.content)
}
// 设置流状态监听 // 设置流状态监听
let unwatchStream = null let unwatchStream = null
@ -254,6 +295,8 @@ export function useConversations() {
createConv, createConv,
deleteConv, deleteConv,
updateConvTitle, updateConvTitle,
deleteMessage,
regenerateMessage,
loadEnabledTools, loadEnabledTools,
init, init,
cleanup cleanup

View File

@ -69,21 +69,18 @@
</div> </div>
<div v-else-if="convMessages.length || currentStreamState"> <div v-else-if="convMessages.length || currentStreamState">
<!-- 历史消息 --> <!-- 历史消息 -->
<div v-for="msg in convMessages" :key="msg.id" class="chat-message" :class="msg.role" :data-msg-id="msg.id"> <MessageBubble
<div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div> v-for="msg in convMessages"
<div class="message-content"> :key="msg.id"
<!-- 工具调用步骤显示包含思考和文本内容 --> :message="msg"
<ProcessBlock :deletable="msg.role === 'user'"
v-if="msg.process_steps && msg.process_steps.length" :data-msg-id="msg.id"
:process-steps="msg.process_steps" @delete="handleDeleteMessage"
/> @regenerate="handleRegenerateMessage"
<!-- 或仅显示消息内容 --> />
<div v-else class="message-text" v-html="renderMsgContent(msg)"></div>
</div>
</div>
<!-- 流式消息 - 使用 store 中的状态 --> <!-- 流式消息 - 使用 store 中的状态 -->
<div v-if="currentStreamState" class="chat-message assistant streaming"> <div v-if="currentStreamState" class="chat-message assistant streaming">
<div class="message-avatar">🤖</div> <div class="message-avatar">Luxx</div>
<div class="message-content"> <div class="message-content">
<ProcessBlock <ProcessBlock
:process-steps="currentStreamState.process_steps" :process-steps="currentStreamState.process_steps"
@ -165,12 +162,12 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useConversations } from '../utils/useConversations.js' import { useConversations } from '../utils/useConversations.js'
import { renderMarkdown } from '../utils/markdown.js'
import { formatDate } from '../utils/useFormatters.js' import { formatDate } from '../utils/useFormatters.js'
import ProcessBlock from '../components/ProcessBlock.vue' import ProcessBlock from '../components/ProcessBlock.vue'
import MessageNav from '../components/MessageNav.vue' import MessageNav from '../components/MessageNav.vue'
import MessageBubble from '../components/MessageBubble.vue'
const { const {
list, list,
@ -192,6 +189,8 @@ const {
createConv, createConv,
deleteConv, deleteConv,
updateConvTitle, updateConvTitle,
deleteMessage,
regenerateMessage,
init, init,
cleanup cleanup
} = useConversations() } = useConversations()
@ -208,13 +207,6 @@ const observedElements = new Set()
const editConv = ref(null) const editConv = ref(null)
// Markdown
const renderMsgContent = (msg) => {
const content = msg.content || msg.text || ''
if (!content) return '-'
return renderMarkdown(content)
}
// //
const handleSend = async () => { const handleSend = async () => {
if (!newMessage.value.trim()) return if (!newMessage.value.trim()) return
@ -264,6 +256,24 @@ const handleSaveTitle = async () => {
} }
} }
//
const handleDeleteMessage = async (msgId) => {
try {
await deleteMessage(msgId)
} catch (e) {
alert('删除消息失败: ' + e.message)
}
}
//
const handleRegenerateMessage = async (msgId) => {
try {
await regenerateMessage(msgId)
} catch (e) {
alert('重新生成失败: ' + e.message)
}
}
const onProviderChange = () => { const onProviderChange = () => {
const p = providers.value.find(p => p.id === form.value.provider_id) const p = providers.value.find(p => p.id === form.value.provider_id)

View File

@ -122,8 +122,8 @@ class ChatService:
llm, provider_max_tokens = get_llm_client(conversation) llm, provider_max_tokens = get_llm_client(conversation)
model = conversation.model or llm.default_model or "gpt-4" model = conversation.model or llm.default_model or "gpt-4"
# 使用 provider 的 max_tokens,如果 conversation 有自己的 max_tokens 则覆盖 # 直接使用 provider 的 max_tokens
max_tokens = conversation.max_tokens if hasattr(conversation, 'max_tokens') and conversation.max_tokens else provider_max_tokens max_tokens = provider_max_tokens
# State tracking # State tracking
all_steps = [] all_steps = []