feat: 优化聊天界面布局与交互
This commit is contained in:
parent
ca779ca227
commit
beb1a03d92
|
|
@ -73,3 +73,33 @@ def delete_message(conv_id, msg_id):
|
||||||
db.session.delete(msg)
|
db.session.delete(msg)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok(message="deleted")
|
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)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
:tools-enabled="toolsEnabled"
|
:tools-enabled="toolsEnabled"
|
||||||
@send-message="sendMessage"
|
@send-message="sendMessage"
|
||||||
@delete-message="deleteMessage"
|
@delete-message="deleteMessage"
|
||||||
|
@regenerate-message="regenerateMessage"
|
||||||
@toggle-settings="showSettings = true"
|
@toggle-settings="showSettings = true"
|
||||||
@load-more-messages="loadMoreMessages"
|
@load-more-messages="loadMoreMessages"
|
||||||
@toggle-tools="updateToolsEnabled"
|
@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 --
|
// -- Delete conversation --
|
||||||
async function deleteConversation(id) {
|
async function deleteConversation(id) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -172,4 +172,71 @@ export const messageApi = {
|
||||||
delete(convId, msgId) {
|
delete(convId, msgId) {
|
||||||
return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' })
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
:created-at="msg.created_at"
|
:created-at="msg.created_at"
|
||||||
:deletable="msg.role === 'user'"
|
:deletable="msg.role === 'user'"
|
||||||
@delete="$emit('deleteMessage', msg.id)"
|
@delete="$emit('deleteMessage', msg.id)"
|
||||||
|
@regenerate="$emit('regenerateMessage', msg.id)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="streaming" class="message-bubble assistant streaming">
|
<div v-if="streaming" class="message-bubble assistant streaming">
|
||||||
|
|
@ -98,7 +99,7 @@ const props = defineProps({
|
||||||
toolsEnabled: { type: Boolean, default: true },
|
toolsEnabled: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['sendMessage', 'deleteMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
|
defineEmits(['sendMessage', 'deleteMessage', 'regenerateMessage', 'toggleSettings', 'loadMoreMessages', 'toggleTools'])
|
||||||
|
|
||||||
const scrollContainer = ref(null)
|
const scrollContainer = ref(null)
|
||||||
const inputRef = ref(null)
|
const inputRef = ref(null)
|
||||||
|
|
@ -142,13 +143,12 @@ defineExpose({ scrollToBottom })
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.chat-view {
|
.chat-view {
|
||||||
flex: 1;
|
flex: 1 1 auto; /* 弹性宽度,自动填充剩余空间 */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
min-width: 0;
|
min-width: 300px; /* 最小宽度保证可用性 */
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
@ -255,10 +255,12 @@ defineExpose({ scrollToBottom })
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-container {
|
.messages-container {
|
||||||
flex: 1;
|
flex: 1 1 auto; /* 弹性高度,自动填充 */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 24px;
|
padding: 16px 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-container::-webkit-scrollbar {
|
.messages-container::-webkit-scrollbar {
|
||||||
|
|
@ -292,8 +294,10 @@ defineExpose({ scrollToBottom })
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-list {
|
.messages-list {
|
||||||
max-width: 800px;
|
flex: 0 1 auto; /* 弹性宽度 */
|
||||||
margin: 0 auto;
|
width: 80%;
|
||||||
|
margin: 0 auto; /* 居中显示 */
|
||||||
|
padding: 0 16px; /* 左右内边距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble {
|
.message-bubble {
|
||||||
|
|
@ -301,6 +305,30 @@ defineExpose({ scrollToBottom })
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-bottom: 16px;
|
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 {
|
.message-bubble .avatar {
|
||||||
|
|
@ -329,6 +357,10 @@ defineExpose({ scrollToBottom })
|
||||||
transition: background 0.2s, border-color 0.2s;
|
transition: background 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-bubble.streaming .message-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.streaming-content {
|
.streaming-content {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
<div class="message-bubble" :class="[role]">
|
<div class="message-bubble" :class="[role]">
|
||||||
<div v-if="role === 'user'" class="avatar">user</div>
|
<div v-if="role === 'user'" class="avatar">user</div>
|
||||||
<div v-else class="avatar">claw</div>
|
<div v-else class="avatar">claw</div>
|
||||||
|
<div class="message-container">
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<ProcessBlock
|
<ProcessBlock
|
||||||
v-if="thinkingContent || (toolCalls && toolCalls.length > 0) || (processSteps && processSteps.length > 0)"
|
v-if="thinkingContent || (toolCalls && toolCalls.length > 0) || (processSteps && processSteps.length > 0)"
|
||||||
|
|
@ -14,9 +15,16 @@
|
||||||
<pre>{{ content }}</pre>
|
<pre>{{ content }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="message-content" v-html="renderedContent"></div>
|
<div v-else class="message-content" v-html="renderedContent"></div>
|
||||||
|
</div>
|
||||||
<div class="message-footer">
|
<div class="message-footer">
|
||||||
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
||||||
<span class="message-time">{{ formatTime(createdAt) }}</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="复制">
|
<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">
|
<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>
|
<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 },
|
deletable: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['delete'])
|
defineEmits(['delete', 'regenerate'])
|
||||||
|
|
||||||
const renderedContent = computed(() => {
|
const renderedContent = computed(() => {
|
||||||
if (!props.content) return ''
|
if (!props.content) return ''
|
||||||
|
|
@ -74,12 +82,36 @@ function copyContent() {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-bubble.user {
|
.message-bubble.user {
|
||||||
flex-direction: row-reverse;
|
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 {
|
.avatar {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|
@ -228,13 +260,8 @@ function copyContent() {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
padding: 6px 0 0;
|
||||||
opacity: 0;
|
font-size: 12px;
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-bubble:hover .message-footer {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-count,
|
.token-count,
|
||||||
|
|
@ -243,6 +270,7 @@ function copyContent() {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-regenerate,
|
||||||
.btn-copy,
|
.btn-copy,
|
||||||
.btn-delete-msg {
|
.btn-delete-msg {
|
||||||
background: none;
|
background: none;
|
||||||
|
|
@ -256,6 +284,11 @@ function copyContent() {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-regenerate:hover {
|
||||||
|
color: var(--success-color);
|
||||||
|
background: var(--success-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-copy:hover {
|
.btn-copy:hover {
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
background: var(--accent-primary-light);
|
background: var(--accent-primary-light);
|
||||||
|
|
|
||||||
|
|
@ -73,14 +73,16 @@ function onContextMenu(e, conv) {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 280px;
|
flex: 0 1 auto; /* 弹性宽度,可收缩 */
|
||||||
min-width: 280px;
|
width: 260px; /* 默认宽度 */
|
||||||
|
min-width: 180px; /* 最小宽度 */
|
||||||
|
max-width: 320px; /* 最大宽度 */
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-right: 1px solid var(--border-medium);
|
border-right: 1px solid var(--border-medium);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
transition: background 0.2s, border-color 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue