MasterChatDrawer.vue 10.6 KB
<template>
  <div class="master-drawer" :class="{ 'is-open': isOpen }">
    <div class="drawer-toggle" @click="isOpen = !isOpen">
      <span class="pulse-icon">🤖</span>
      <span class="toggle-text">Master</span>
    </div>
    
    <div class="drawer-content">
      <div class="drawer-header">
        <div class="header-left">
          <span class="master-icon">⚡</span>
          <h3>MasterBot</h3>
        </div>
        <button class="close-btn" @click="isOpen = false">✕</button>
      </div>

      <div class="chat-main" ref="messageListRef">
        <div v-if="messages.length === 0 && !streaming" class="empty-chat">
          <div class="empty-icon">🤖</div>
          <p>我是系统的 MasterBot,负责全局调度。<br>你可以直接向我下达指令,例如创建公司或招聘 Agent。</p>
        </div>

        <div v-else class="message-list">
          <div v-for="(msg, idx) in messages" :key="idx" class="msg-row" :class="`msg-${msg.role}`">
            <div class="msg-avatar" :class="`avatar-${msg.role}`">
              {{ msg.role === 'user' ? '👤' : '🤖' }}
            </div>
            <div class="msg-body">
              <div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
            </div>
          </div>

          <div v-if="streaming" class="msg-row msg-assistant streaming">
            <div class="msg-avatar avatar-assistant">🤖</div>
            <div class="msg-body">
              <div class="msg-content" v-html="renderMarkdown(streamContent)"></div>
              <span class="typing-indicator"><span></span><span></span><span></span></span>
            </div>
          </div>

          <div v-if="sending && thinkingText" class="thinking-row">
            <div class="thinking-pulse"></div>
            <span class="thinking-text">{{ thinkingText }}</span>
          </div>
        </div>
      </div>

      <div class="chat-input-area">
        <textarea 
          ref="inputRef" 
          v-model="inputText" 
          :disabled="sending" 
          placeholder="给 MasterBot 发送指令..." 
          class="msg-textarea" 
          rows="1" 
          @keydown="handleKeydown" 
          @input="autoResize"
        ></textarea>
        <button v-if="sending" class="send-btn stop-btn" @click="handleStop">⏹</button>
        <button v-else class="send-btn" :class="{ ready: inputText.trim() && !sending }" :disabled="!inputText.trim()" @click="handleSend">➤</button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, nextTick, watch, onMounted } from 'vue'
import { marked } from 'marked'

const isOpen = ref(false)
const agentId = 'master'
const activeConvId = ref('')

const messages = ref([])
const inputText = ref('')
const sending = ref(false)
const streaming = ref(false)
const streamContent = ref('')
const thinkingText = ref('')
const messageListRef = ref(null)
const inputRef = ref(null)

// Format Markdown
function renderMarkdown(text) {
  try { return marked.parse(text || '', { breaks: true, gfm: true }) }
  catch { return (text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') }
}

async function handleSend() {
  const text = inputText.value.trim()
  if (!text || sending.value) return
  messages.value.push({ role: 'user', content: text, timestamp: new Date().toISOString() })
  inputText.value = ''
  if (inputRef.value) inputRef.value.style.height = 'auto'
  
  sending.value = true
  streaming.value = false
  streamContent.value = ''
  thinkingText.value = '🤖 调度中...'
  scrollToBottom()

  try {
    const r = await fetch(`/api/agent/${agentId}/chat`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: text, conversationId: activeConvId.value }),
    })
    const reader = r.body.getReader()
    const decoder = new TextDecoder()
    let buffer = '', lastEventType = ''
    
    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() || ''
      for (const line of lines) {
        if (line.startsWith('event: ')) lastEventType = line.slice(7)
        else if (line.startsWith('data: ')) {
          try { handleEvent(lastEventType, JSON.parse(line.slice(6))) } catch {}
        }
      }
    }
    
    if (streamContent.value) {
      messages.value.push({ role: 'assistant', content: streamContent.value, timestamp: new Date().toISOString() })
    }
  } catch (err) {
    messages.value.push({ role: 'assistant', content: `❌ 错误: ${err.message}` })
  } finally {
    sending.value = false; streaming.value = false; streamContent.value = ''; thinkingText.value = ''
  }
}

function handleEvent(type, data) {
  if (type === 'session' && data.conversationId) {
    activeConvId.value = data.conversationId
  }
  if (type === 'thinking') {
    thinkingText.value = data.message || '系统思考中...'
  }
  if (type === 'agent_output') {
    streaming.value = true
    streamContent.value += (data.message || '')
    scrollToBottom()
  }
  if (type === 'tool_call') {
    thinkingText.value = data.message || `🔧 ${data.tool}`
  }
}

async function handleStop() {
  if (!activeConvId.value) return
  try { await fetch(`/api/agent/${agentId}/chat/${activeConvId.value}/stop`, { method: 'POST' }) } catch(e) {}
  sending.value = false; streaming.value = false
  messages.value.push({ role: 'assistant', content: `*[已终止]*` })
  scrollToBottom()
}

function handleKeydown(e) { 
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { 
    e.preventDefault(); handleSend() 
  } 
}

function autoResize() { 
  const el = inputRef.value; if (!el) return; 
  el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px' 
}

function scrollToBottom() { 
  nextTick(() => { 
    if (messageListRef.value) messageListRef.value.scrollTop = messageListRef.value.scrollHeight 
  }) 
}

watch([messages, streamContent, thinkingText], () => scrollToBottom(), { deep: true })
</script>

<style scoped>
.master-drawer {
  position: fixed;
  right: -360px;
  top: 41px; /* below nav */
  bottom: 0;
  width: 360px;
  background: var(--bg-secondary);
  border-left: 1px solid var(--border-primary);
  box-shadow: -4px 0 24px rgba(0,0,0,0.1);
  transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  z-index: 95;
  display: flex;
  flex-direction: column;
}
.master-drawer.is-open { right: 0; }

.drawer-toggle {
  position: absolute;
  left: -48px;
  top: 100px;
  width: 48px;
  height: 48px;
  background: var(--bg-secondary);
  border: 1px solid var(--border-primary);
  border-right: none;
  border-radius: 12px 0 0 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: -4px 4px 12px rgba(0,0,0,0.05);
  transition: background 0.2s;
}
.drawer-toggle:hover { background: var(--bg-surface-hover); }
.pulse-icon { font-size: 20px; animation: float 3s ease-in-out infinite; }
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-3px)} }
.toggle-text { font-size: 9px; font-weight: 700; color: var(--text-tertiary); margin-top: 2px; }

.drawer-content {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.drawer-header {
  padding: 16px;
  border-bottom: 1px solid var(--border-primary);
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: var(--bg-primary);
}
.header-left { display: flex; align-items: center; gap: 8px; }
.master-icon { font-size: 20px; filter: drop-shadow(0 0 8px rgba(16,185,129,0.5)); }
.drawer-header h3 { font-size: 15px; font-weight: 700; margin: 0; color: var(--text-primary); }
.close-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 16px; }

.chat-main {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  background: var(--bg-primary);
}
.empty-chat { text-align: center; margin-top: 60px; color: var(--text-muted); }
.empty-icon { font-size: 40px; margin-bottom: 16px; opacity: 0.5; }

.message-list { display: flex; flex-direction: column; gap: 16px; }
.msg-row { display: flex; gap: 10px; max-width: 95%; }
.msg-user { align-self: flex-end; flex-direction: row-reverse; }
.msg-assistant { align-self: flex-start; }
.msg-avatar { width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
.avatar-user { background: var(--avatar-user-bg); }
.avatar-assistant { background: var(--avatar-assistant-bg); }
.msg-body { flex: 1; min-width: 0; }
.msg-content { padding: 10px 14px; border-radius: 12px; font-size: 13px; line-height: 1.6; }
.msg-user .msg-content { background: var(--msg-user-bg); border: 1px solid var(--msg-user-border); color: var(--text-primary); }
.msg-assistant .msg-content { background: var(--msg-assistant-bg); border: 1px solid var(--msg-assistant-border); color: var(--text-secondary); }
.msg-content :deep(pre) { background: var(--bg-code); padding: 10px; border-radius: 8px; overflow-x: auto; font-size: 11px; margin: 6px 0; }
.msg-content :deep(p) { margin: 0 0 6px; } .msg-content :deep(p:last-child) { margin: 0; }

.thinking-row { display: flex; align-items: center; gap: 8px; padding: 10px 0; }
.thinking-pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: glow 1.5s infinite; }
@keyframes glow { 0%,100%{opacity:1} 50%{opacity:0.3} }
.thinking-text { font-size: 12px; color: var(--text-muted); font-family: monospace; }
.typing-indicator span { display: inline-block; width: 4px; height: 4px; border-radius: 50%; background: var(--text-muted); margin: 0 2px; animation: bounce 1.4s infinite; }
@keyframes bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-4px)} }

.chat-input-area { padding: 16px; border-top: 1px solid var(--border-primary); background: var(--bg-secondary); display: flex; gap: 10px; align-items: flex-end; }
.msg-textarea { flex: 1; border: 1px solid var(--border-primary); outline: none; background: var(--bg-input); color: var(--text-primary); font-size: 13px; line-height: 1.5; resize: none; padding: 10px 12px; border-radius: 10px; max-height: 120px; transition: border 0.2s; }
.msg-textarea:focus { border-color: var(--accent); background: var(--bg-input-focus); }
.send-btn { width: 40px; height: 40px; border-radius: 10px; border: none; font-size: 16px; cursor: pointer; transition: all 0.2s; flex-shrink: 0; background: var(--bg-input); color: var(--text-muted); }
.send-btn.ready { background: linear-gradient(135deg,var(--accent),var(--accent-light)); color: #fff; box-shadow: 0 4px 12px rgba(16,185,129,0.3); }
.send-btn.ready:hover { transform: translateY(-2px); }
</style>