feat: 优化聊天界面布局与交互

This commit is contained in:
ViperEkura 2026-03-25 21:41:57 +08:00
parent ca779ca227
commit beb1a03d92
6 changed files with 288 additions and 30 deletions

View File

@ -73,3 +73,33 @@ def delete_message(conv_id, msg_id):
db.session.delete(msg)
db.session.commit()
return ok(message="deleted")
@bp.route("/api/conversations/<conv_id>/regenerate/<msg_id>", methods=["POST"])
def regenerate_message(conv_id, msg_id):
"""Regenerate an assistant message"""
conv = db.session.get(Conversation, conv_id)
if not conv:
return err(404, "conversation not found")
# 获取要重新生成的消息
msg = db.session.get(Message, msg_id)
if not msg or msg.conversation_id != conv_id:
return err(404, "message not found")
if msg.role != "assistant":
return err(400, "can only regenerate assistant messages")
# 删除该消息及其后面的所有消息
Message.query.filter(
Message.conversation_id == conv_id,
Message.created_at >= msg.created_at
).delete(synchronize_session=False)
db.session.commit()
# 获取工具启用状态
d = request.json or {}
tools_enabled = d.get("tools_enabled", True)
# 流式重新生成
return _chat_service.stream_response(conv, tools_enabled)

View File

@ -25,6 +25,7 @@
:tools-enabled="toolsEnabled"
@send-message="sendMessage"
@delete-message="deleteMessage"
@regenerate-message="regenerateMessage"
@toggle-settings="showSettings = true"
@load-more-messages="loadMoreMessages"
@toggle-tools="updateToolsEnabled"
@ -359,6 +360,99 @@ async function deleteMessage(msgId) {
}
}
// -- Regenerate message --
async function regenerateMessage(msgId) {
if (!currentConvId.value || streaming.value) return
const convId = currentConvId.value
//
const msgIndex = messages.value.findIndex(m => m.id === msgId)
if (msgIndex === -1) return
//
messages.value = messages.value.slice(0, msgIndex)
streaming.value = true
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
currentStreamPromise = messageApi.regenerate(convId, msgId, {
toolsEnabled: toolsEnabled.value,
onThinkingStart() {
if (currentConvId.value === convId) {
streamThinking.value = ''
}
},
onThinking(text) {
if (currentConvId.value === convId) {
streamThinking.value += text
}
},
onMessage(text) {
if (currentConvId.value === convId) {
streamContent.value += text
}
},
onToolCalls(calls) {
if (currentConvId.value === convId) {
streamToolCalls.value.push(...calls.map(c => ({ ...c, result: null })))
}
},
onToolResult(result) {
if (currentConvId.value === convId) {
const call = streamToolCalls.value.find(c => c.id === result.id)
if (call) call.result = result.content
}
},
onProcessStep(step) {
const idx = step.index
if (currentConvId.value === convId) {
const newSteps = [...streamProcessSteps.value]
while (newSteps.length <= idx) {
newSteps.push(null)
}
newSteps[idx] = step
streamProcessSteps.value = newSteps
}
},
async onDone(data) {
if (currentConvId.value === convId) {
streaming.value = false
currentStreamPromise = null
messages.value.push({
id: data.message_id,
conversation_id: convId,
role: 'assistant',
content: streamContent.value,
token_count: data.token_count,
thinking_content: streamThinking.value || null,
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
process_steps: streamProcessSteps.value.filter(Boolean),
created_at: new Date().toISOString(),
})
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
}
},
onError(msg) {
if (currentConvId.value === convId) {
streaming.value = false
currentStreamPromise = null
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
console.error('Regenerate error:', msg)
}
},
})
}
// -- Delete conversation --
async function deleteConversation(id) {
try {

View File

@ -172,4 +172,71 @@ export const messageApi = {
delete(convId, msgId) {
return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' })
},
regenerate(convId, msgId, { toolsEnabled = true, onThinkingStart, onThinking, onMessage, onToolCalls, onToolResult, onProcessStep, onDone, onError } = {}) {
const controller = new AbortController()
const promise = (async () => {
try {
const res = await fetch(`${BASE}/conversations/${convId}/regenerate/${msgId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tools_enabled: toolsEnabled }),
signal: controller.signal,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.message || `HTTP ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
let currentEvent = ''
for (const line of lines) {
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim()
} else if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6))
if (currentEvent === 'thinking_start' && onThinkingStart) {
onThinkingStart()
} else if (currentEvent === 'thinking' && onThinking) {
onThinking(data.content)
} else if (currentEvent === 'message' && onMessage) {
onMessage(data.content)
} else if (currentEvent === 'tool_calls' && onToolCalls) {
onToolCalls(data.calls)
} else if (currentEvent === 'tool_result' && onToolResult) {
onToolResult(data)
} else if (currentEvent === 'process_step' && onProcessStep) {
onProcessStep(data)
} else if (currentEvent === 'done' && onDone) {
onDone(data)
} else if (currentEvent === 'error' && onError) {
onError(data.content)
}
}
}
}
} catch (e) {
if (e.name !== 'AbortError' && onError) {
onError(e.message)
}
}
})()
promise.abort = () => controller.abort()
return promise
},
}

View File

@ -44,6 +44,7 @@
:created-at="msg.created_at"
:deletable="msg.role === 'user'"
@delete="$emit('deleteMessage', msg.id)"
@regenerate="$emit('regenerateMessage', msg.id)"
/>
<div v-if="streaming" class="message-bubble assistant streaming">
@ -98,7 +99,7 @@ const props = defineProps({
toolsEnabled: { type: Boolean, default: true },
})
defineEmits(['sendMessage', 'deleteMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
const scrollContainer = ref(null)
const inputRef = ref(null)
@ -142,13 +143,12 @@ defineExpose({ scrollToBottom })
<style scoped>
.chat-view {
flex: 1;
flex: 1 1 auto; /* 弹性宽度,自动填充剩余空间 */
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-secondary);
min-width: 0;
max-width: 100%;
min-width: 300px; /* 最小宽度保证可用性 */
overflow: hidden;
transition: background 0.2s;
}
@ -255,10 +255,12 @@ defineExpose({ scrollToBottom })
}
.messages-container {
flex: 1;
flex: 1 1 auto; /* 弹性高度,自动填充 */
overflow-y: auto;
padding: 0 24px;
padding: 16px 0;
width: 100%;
display: flex;
flex-direction: column;
}
.messages-container::-webkit-scrollbar {
@ -292,8 +294,10 @@ defineExpose({ scrollToBottom })
}
.messages-list {
max-width: 800px;
margin: 0 auto;
flex: 0 1 auto; /* 弹性宽度 */
width: 80%;
margin: 0 auto; /* 居中显示 */
padding: 0 16px; /* 左右内边距 */
}
.message-bubble {
@ -301,6 +305,30 @@ defineExpose({ scrollToBottom })
gap: 12px;
padding: 0;
margin-bottom: 16px;
width: 100%;
}
.message-bubble.assistant {
width: 100%;
}
.message-bubble.assistant.streaming {
width: 100%;
}
.message-bubble .message-container {
display: flex;
flex-direction: column;
max-width: 85%;
min-width: 200px;
}
.message-bubble.user .message-container {
align-items: flex-end;
}
.message-bubble.assistant .message-container {
align-items: flex-start;
}
.message-bubble .avatar {
@ -329,6 +357,10 @@ defineExpose({ scrollToBottom })
transition: background 0.2s, border-color 0.2s;
}
.message-bubble.streaming .message-body {
flex: 1;
}
.streaming-content {
font-size: 15px;
line-height: 1.7;

View File

@ -2,6 +2,7 @@
<div class="message-bubble" :class="[role]">
<div v-if="role === 'user'" class="avatar">user</div>
<div v-else class="avatar">claw</div>
<div class="message-container">
<div class="message-body">
<ProcessBlock
v-if="thinkingContent || (toolCalls && toolCalls.length > 0) || (processSteps && processSteps.length > 0)"
@ -14,9 +15,16 @@
<pre>{{ content }}</pre>
</div>
<div v-else class="message-content" v-html="renderedContent"></div>
</div>
<div class="message-footer">
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
<span class="message-time">{{ formatTime(createdAt) }}</span>
<button v-if="role === 'assistant'" class="btn-regenerate" @click="$emit('regenerate')" title="重新生成">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 4v6h6"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
</button>
<button v-if="role === 'assistant'" class="btn-copy" @click="copyContent" title="复制">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
@ -51,7 +59,7 @@ const props = defineProps({
deletable: { type: Boolean, default: false },
})
defineEmits(['delete'])
defineEmits(['delete', 'regenerate'])
const renderedContent = computed(() => {
if (!props.content) return ''
@ -74,12 +82,36 @@ function copyContent() {
gap: 12px;
padding: 0;
margin-bottom: 16px;
width: 100%;
}
.message-bubble.user {
flex-direction: row-reverse;
}
.message-container {
display: flex;
flex-direction: column;
min-width: 200px;
width: 100%;
}
.message-bubble.user .message-container {
align-items: flex-end;
width: fit-content;
max-width: 85%;
}
.message-bubble.assistant .message-container {
align-items: flex-start;
flex: 1 1 auto;
min-width: 0;
}
.message-bubble.assistant .message-body {
width: 100%;
}
.avatar {
width: 32px;
height: 32px;
@ -228,13 +260,8 @@ function copyContent() {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
opacity: 0;
transition: opacity 0.15s;
}
.message-bubble:hover .message-footer {
opacity: 1;
padding: 6px 0 0;
font-size: 12px;
}
.token-count,
@ -243,6 +270,7 @@ function copyContent() {
color: var(--text-tertiary);
}
.btn-regenerate,
.btn-copy,
.btn-delete-msg {
background: none;
@ -256,6 +284,11 @@ function copyContent() {
align-items: center;
}
.btn-regenerate:hover {
color: var(--success-color);
background: var(--success-bg);
}
.btn-copy:hover {
color: var(--accent-primary);
background: var(--accent-primary-light);

View File

@ -73,14 +73,16 @@ function onContextMenu(e, conv) {
<style scoped>
.sidebar {
width: 280px;
min-width: 280px;
flex: 0 1 auto; /* 弹性宽度,可收缩 */
width: 260px; /* 默认宽度 */
min-width: 180px; /* 最小宽度 */
max-width: 320px; /* 最大宽度 */
background: var(--bg-primary);
border-right: 1px solid var(--border-medium);
display: flex;
flex-direction: column;
height: 100vh;
transition: background 0.2s, border-color 0.2s;
transition: all 0.2s;
}
.sidebar-header {