nanoClaw/frontend/src/App.vue

605 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app">
<Sidebar
:conversations="conversations"
:current-id="currentConvId"
:loading="loadingConvs"
:has-more="hasMoreConvs"
@select="selectConversation"
@create="createConversation"
@delete="deleteConversation"
@load-more="loadMoreConversations"
/>
<ChatView
ref="chatViewRef"
:conversation="currentConv"
:messages="messages"
:streaming="streaming"
:streaming-content="streamContent"
:streaming-thinking="streamThinking"
:streaming-tool-calls="streamToolCalls"
:streaming-process-steps="streamProcessSteps"
:has-more-messages="hasMoreMessages"
:loading-more="loadingMessages"
:tools-enabled="toolsEnabled"
@send-message="sendMessage"
@delete-message="deleteMessage"
@regenerate-message="regenerateMessage"
@toggle-settings="showSettings = true"
@load-more-messages="loadMoreMessages"
@toggle-tools="updateToolsEnabled"
/>
<SettingsPanel
:visible="showSettings"
:conversation="currentConv"
@close="showSettings = false"
@save="saveSettings"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import Sidebar from './components/Sidebar.vue'
import ChatView from './components/ChatView.vue'
import SettingsPanel from './components/SettingsPanel.vue'
import { conversationApi, messageApi } from './api'
const chatViewRef = ref(null)
// -- Conversations state --
const conversations = ref([])
const currentConvId = ref(null)
const loadingConvs = ref(false)
const hasMoreConvs = ref(false)
const nextConvCursor = ref(null)
// -- Messages state --
const messages = ref([])
const hasMoreMessages = ref(false)
const loadingMessages = ref(false)
const nextMsgCursor = ref(null)
// -- Streaming state --
const streaming = ref(false)
const streamContent = ref('')
const streamThinking = ref('')
const streamToolCalls = ref([])
const streamProcessSteps = ref([])
// 保存每个对话的流式状态
const streamStates = new Map()
// 保存当前流式请求引用
let currentStreamPromise = null
// -- UI state --
const showSettings = ref(false)
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
const currentConv = computed(() =>
conversations.value.find(c => c.id === currentConvId.value) || null
)
// -- Load conversations --
async function loadConversations(reset = true) {
if (loadingConvs.value) return
loadingConvs.value = true
try {
const res = await conversationApi.list(reset ? null : nextConvCursor.value)
if (reset) {
conversations.value = res.data.items
} else {
conversations.value.push(...res.data.items)
}
nextConvCursor.value = res.data.next_cursor
hasMoreConvs.value = res.data.has_more
} catch (e) {
console.error('Failed to load conversations:', e)
} finally {
loadingConvs.value = false
}
}
function loadMoreConversations() {
if (hasMoreConvs.value) loadConversations(false)
}
// -- Create conversation --
async function createConversation() {
try {
const res = await conversationApi.create({ title: '新对话' })
conversations.value.unshift(res.data)
await selectConversation(res.data.id)
} catch (e) {
console.error('Failed to create conversation:', e)
}
}
// -- Select conversation --
async function selectConversation(id) {
// 保存当前对话的流式状态和消息列表(如果有)
if (currentConvId.value && streaming.value) {
streamStates.set(currentConvId.value, {
streaming: true,
streamContent: streamContent.value,
streamThinking: streamThinking.value,
streamToolCalls: [...streamToolCalls.value],
streamProcessSteps: [...streamProcessSteps.value],
messages: [...messages.value], // 保存消息列表(包括临时用户消息)
})
}
currentConvId.value = id
nextMsgCursor.value = null
hasMoreMessages.value = false
// 恢复新对话的流式状态
const savedState = streamStates.get(id)
if (savedState && savedState.streaming) {
streaming.value = true
streamContent.value = savedState.streamContent
streamThinking.value = savedState.streamThinking
streamToolCalls.value = savedState.streamToolCalls
streamProcessSteps.value = savedState.streamProcessSteps
messages.value = savedState.messages || [] // 恢复消息列表
} else {
streaming.value = false
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
messages.value = []
}
// 如果不是正在流式传输,从服务器加载消息
if (!streaming.value) {
await loadMessages(true)
}
}
// -- Load messages --
async function loadMessages(reset = true) {
if (!currentConvId.value || loadingMessages.value) return
loadingMessages.value = true
try {
const res = await messageApi.list(currentConvId.value, reset ? null : nextMsgCursor.value)
if (reset) {
// Filter out tool messages (they're merged into assistant messages)
messages.value = res.data.items.filter(m => m.role !== 'tool')
} else {
messages.value = [...res.data.items.filter(m => m.role !== 'tool'), ...messages.value]
}
nextMsgCursor.value = res.data.next_cursor
hasMoreMessages.value = res.data.has_more
} catch (e) {
console.error('Failed to load messages:', e)
} finally {
loadingMessages.value = false
}
}
function loadMoreMessages() {
if (hasMoreMessages.value) loadMessages(false)
}
// -- Send message (streaming) --
async function sendMessage(data) {
if (!currentConvId.value || streaming.value) return
const convId = currentConvId.value // 保存当前对话ID
const text = data.text || ''
const attachments = data.attachments || null
// Add user message optimistically
const userMsg = {
id: 'temp_' + Date.now(),
conversation_id: convId,
role: 'user',
text,
attachments: attachments ? attachments.map(a => ({ name: a.name, extension: a.extension })) : null,
token_count: 0,
created_at: new Date().toISOString(),
}
messages.value.push(userMsg)
streaming.value = true
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
currentStreamPromise = messageApi.send(convId, { text, attachments }, {
stream: true,
toolsEnabled: toolsEnabled.value,
onThinkingStart() {
if (currentConvId.value === convId) {
streamThinking.value = ''
} else {
const saved = streamStates.get(convId) || {}
streamStates.set(convId, { ...saved, streamThinking: '' })
}
},
onThinking(text) {
if (currentConvId.value === convId) {
streamThinking.value += text
} else {
const saved = streamStates.get(convId) || { streamThinking: '' }
streamStates.set(convId, { ...saved, streamThinking: (saved.streamThinking || '') + text })
}
},
onMessage(text) {
if (currentConvId.value === convId) {
streamContent.value += text
} else {
const saved = streamStates.get(convId) || { streamContent: '' }
streamStates.set(convId, { ...saved, streamContent: (saved.streamContent || '') + text })
}
},
onToolCalls(calls) {
console.log('🔧 Tool calls received:', calls)
if (currentConvId.value === convId) {
streamToolCalls.value.push(...calls.map(c => ({ ...c, result: null })))
} else {
const saved = streamStates.get(convId) || { streamToolCalls: [] }
const newCalls = [...(saved.streamToolCalls || []), ...calls.map(c => ({ ...c, result: null }))]
streamStates.set(convId, { ...saved, streamToolCalls: newCalls })
}
},
onToolResult(result) {
console.log('✅ Tool result received:', result)
if (currentConvId.value === convId) {
const call = streamToolCalls.value.find(c => c.id === result.id)
if (call) call.result = result.content
} else {
const saved = streamStates.get(convId) || { streamToolCalls: [] }
const call = saved.streamToolCalls?.find(c => c.id === result.id)
if (call) call.result = result.content
streamStates.set(convId, { ...saved })
}
},
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
} else {
const saved = streamStates.get(convId) || { streamProcessSteps: [] }
const steps = [...(saved.streamProcessSteps || [])]
while (steps.length <= idx) steps.push(null)
steps[idx] = step
streamStates.set(convId, { ...saved, streamProcessSteps: steps })
}
},
async onDone(data) {
// 清除保存的状态
streamStates.delete(convId)
if (currentConvId.value === convId) {
streaming.value = false
currentStreamPromise = null
// 添加助手消息(保留临时用户消息)
messages.value.push({
id: data.message_id,
conversation_id: convId,
role: 'assistant',
text: streamContent.value,
thinking: streamThinking.value || null,
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
process_steps: streamProcessSteps.value.filter(Boolean),
token_count: data.token_count,
created_at: new Date().toISOString(),
})
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
// Update conversation in list (move to top)
const idx = conversations.value.findIndex(c => c.id === convId)
if (idx > 0) {
const [conv] = conversations.value.splice(idx, 1)
conv.message_count = (conv.message_count || 0) + 2
if (data.suggested_title) {
conv.title = data.suggested_title
}
conversations.value.unshift(conv)
} else if (idx === 0) {
conversations.value[0].message_count = (conversations.value[0].message_count || 0) + 2
if (data.suggested_title) {
conversations.value[0].title = data.suggested_title
}
}
} else {
// 后台完成,重新加载该对话的消息
try {
const res = await messageApi.list(convId, null, 50)
// 更新对话列表中的消息计数和标题
const idx = conversations.value.findIndex(c => c.id === convId)
if (idx >= 0) {
conversations.value[idx].message_count = res.data.items.length
// 从服务器获取最新标题
if (res.data.items.length > 0) {
const convRes = await conversationApi.get(convId)
if (convRes.data.title) {
conversations.value[idx].title = convRes.data.title
}
}
}
} catch (_) {}
}
},
onError(msg) {
streamStates.delete(convId)
if (currentConvId.value === convId) {
streaming.value = false
currentStreamPromise = null
streamContent.value = ''
streamThinking.value = ''
streamToolCalls.value = []
streamProcessSteps.value = []
console.error('Stream error:', msg)
}
},
})
}
// -- Delete message --
async function deleteMessage(msgId) {
if (!currentConvId.value) return
try {
await messageApi.delete(currentConvId.value, msgId)
messages.value = messages.value.filter(m => m.id !== msgId)
} catch (e) {
console.error('Failed to delete message:', e)
}
}
// -- 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',
text: streamContent.value,
thinking: streamThinking.value || null,
tool_calls: streamToolCalls.value.length > 0 ? streamToolCalls.value : null,
process_steps: streamProcessSteps.value.filter(Boolean),
token_count: data.token_count,
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 {
await conversationApi.delete(id)
conversations.value = conversations.value.filter(c => c.id !== id)
if (currentConvId.value === id) {
currentConvId.value = conversations.value.length > 0 ? conversations.value[0].id : null
if (currentConvId.value) {
await selectConversation(currentConvId.value)
} else {
messages.value = []
}
}
} catch (e) {
console.error('Failed to delete conversation:', e)
}
}
// -- Save settings --
async function saveSettings(data) {
if (!currentConvId.value) return
try {
const res = await conversationApi.update(currentConvId.value, data)
const idx = conversations.value.findIndex(c => c.id === currentConvId.value)
if (idx !== -1) {
conversations.value[idx] = { ...conversations.value[idx], ...res.data }
}
} catch (e) {
console.error('Failed to save settings:', e)
}
}
// -- Update tools enabled --
function updateToolsEnabled(val) {
toolsEnabled.value = val
localStorage.setItem('tools_enabled', String(val))
}
// -- Init --
onMounted(() => {
loadConversations()
})
</script>
<style>
:root {
/* Light theme */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f0f4f8;
--bg-hover: rgba(37, 99, 235, 0.06);
--bg-active: rgba(37, 99, 235, 0.12);
--bg-input: #f8fafc;
--bg-code: #f1f5f9;
--bg-thinking: #f1f5f9;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-tertiary: #94a3b8;
--border-light: rgba(0, 0, 0, 0.06);
--border-medium: rgba(0, 0, 0, 0.08);
--border-input: rgba(0, 0, 0, 0.08);
--accent-primary: #2563eb;
--accent-primary-hover: #3b82f6;
--accent-primary-light: rgba(37, 99, 235, 0.08);
--accent-primary-medium: rgba(37, 99, 235, 0.15);
--success-color: #059669;
--success-bg: rgba(16, 185, 129, 0.1);
--danger-color: #ef4444;
--danger-bg: rgba(239, 68, 68, 0.08);
--scrollbar-thumb: rgba(0, 0, 0, 0.08);
--scrollbar-thumb-sidebar: rgba(0, 0, 0, 0.1);
--overlay-bg: rgba(0, 0, 0, 0.3);
--avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa);
}
[data-theme="dark"] {
/* Dark theme - 保持与浅色模式相同的相对色差 */
--bg-primary: #1a1a1a; /* 聊天框,最浅(对应浅色 #ffffff */
--bg-secondary: #141414; /* 侧边栏,中等(对应浅色 #f8fafc */
--bg-tertiary: #0a0a0a; /* 整体背景,最深(对应浅色 #f0f4f8 */
--bg-hover: rgba(255, 255, 255, 0.08);
--bg-active: rgba(255, 255, 255, 0.12);
--bg-input: #141414;
--bg-code: #141414;
--bg-thinking: #141414;
--text-primary: #f0f0f0;
--text-secondary: #a0a0a0;
--text-tertiary: #606060;
--border-light: rgba(255, 255, 255, 0.08);
--border-medium: rgba(255, 255, 255, 0.12);
--border-input: rgba(255, 255, 255, 0.1);
--accent-primary: #3b82f6;
--accent-primary-hover: #60a5fa;
--accent-primary-light: rgba(59, 130, 246, 0.15);
--accent-primary-medium: rgba(59, 130, 246, 0.25);
--success-color: #34d399;
--success-bg: rgba(52, 211, 153, 0.15);
--danger-color: #f87171;
--danger-bg: rgba(248, 113, 113, 0.15);
--scrollbar-thumb: rgba(255, 255, 255, 0.1);
--scrollbar-thumb-sidebar: rgba(255, 255, 255, 0.15);
--overlay-bg: rgba(0, 0, 0, 0.6);
--avatar-gradient: linear-gradient(135deg, #3b82f6, #60a5fa);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
background: var(--bg-tertiary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
transition: background 0.2s, color 0.2s;
}
#app {
height: 100%;
}
.app {
display: flex;
height: 100%;
}
</style>