GraphChatBar.vue 11.6 KB
<template>
  <div class="chat-bar" :class="{ expanded }">
    <div class="bar-row" @click="!expanded && (expanded = true)">
      <span class="bar-avatar">🤖</span>
      <span class="bar-name">{{ agentId }}</span>
      <span v-if="context" class="context-chip" :title="contextTooltip">🔗 {{ contextLabel }}</span>
      <div class="bar-input" @click.stop>
        <textarea
          ref="inputRef"
          v-model="inputText"
          :disabled="sending"
          placeholder="跟 CEO 聊两句…"
          class="msg-textarea"
          rows="1"
          @keydown="handleKeydown"
          @input="autoResize"
          @focus="expanded = true"
        ></textarea>
        <button v-if="sending" class="send-btn stop-btn" title="停止" @click="handleStop">⏹</button>
        <button v-else class="send-btn" :class="{ ready: inputText.trim() && !sending }" :disabled="!inputText.trim()" @click="handleSend">➤</button>
      </div>
      <button class="collapse-btn" :title="expanded ? '收起' : '展开'" @click.stop="expanded = !expanded">{{ expanded ? '▾' : '▴' }}</button>
    </div>

    <div v-if="expanded" class="panel">
      <div class="message-list" ref="messageListRef">
        <div v-if="messages.length === 0 && !streaming" class="empty-chat">跟 CEO 聊两句吧,选中的任务/目标会自动带给它。</div>
        <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>
</template>

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

const props = defineProps({
  companyId: { type: String, required: true },
  agentId: { type: String, default: 'ceo' },
  context: { type: Object, default: null }, // { taskId, taskTitle, taskStatus, goalId, goalTitle }
})

const expanded = ref(false)
const activeConvId = ref('')
const CONV_KEY = `lemonboss.graphChatBar.conv.${props.companyId}`

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)

const lastSentContextKey = ref('')

const contextLabel = computed(() => {
  const c = props.context
  if (!c) return ''
  return `${c.taskTitle || c.taskId}`
})
const contextTooltip = computed(() => {
  const c = props.context
  if (!c) return ''
  return `任务 ${c.taskId}《${c.taskTitle}》(${c.taskStatus}) · 目标 ${c.goalId}《${c.goalTitle}》`
})

function contextKey(c) {
  if (!c) return ''
  return `${c.taskId || ''}|${c.goalId || ''}`
}

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 hydrateHistory() {
  if (!activeConvId.value) return
  try {
    const r = await fetch(`/api/agent/${props.agentId}/conversations/${activeConvId.value}`)
    if (!r.ok) return
    const j = await r.json()
    messages.value = (j.messages || []).map(m => ({ role: m.role, content: m.content, timestamp: m.timestamp }))
  } catch (e) { /* 历史拉取失败不影响继续新对话 */ }
}

onMounted(() => {
  activeConvId.value = localStorage.getItem(CONV_KEY) || ''
  hydrateHistory()
})

async function handleSend() {
  const text = inputText.value.trim()
  if (!text || sending.value) return

  const key = contextKey(props.context)
  let payload = text
  if (props.context && key !== lastSentContextKey.value) {
    payload = `[当前上下文] 任务 ${props.context.taskId}《${props.context.taskTitle}》(状态: ${props.context.taskStatus}) · 目标 ${props.context.goalId}《${props.context.goalTitle}》\n\n${text}`
    lastSentContextKey.value = key
  }

  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/${props.agentId}/chat`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: payload, conversationId: activeConvId.value }),
    })
    if (!r.ok) {
      const errBody = await r.json().catch(() => ({}))
      messages.value.push({ role: 'assistant', content: `❌ 错误 (${r.status}): ${errBody.error || r.statusText}` })
      return
    }
    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) {
    activeConvId.value = data.conversationId
    localStorage.setItem(CONV_KEY, activeConvId.value)
  }
  if (type === 'thinking') {
    thinkingText.value = data.message || '思考中...'
  }
  if (type === 'queued') {
    const waitSec = Math.round((data.waitBudgetMs || 0) / 1000)
    thinkingText.value = `⏳ 排队等待中,最多等待 ${waitSec} 秒`
  }
  if (type === 'queue_timeout') {
    messages.value.push({ role: 'assistant', content: `⏳ ${data.error || 'Agent 排队等待超时,请稍后重试'}` })
  }
  if (type === 'agent_output') {
    streaming.value = true
    streamContent.value += (data.message || '')
    scrollToBottom()
  }
  if (type === 'tool_call') {
    thinkingText.value = data.message || `🔧 ${data.tool}`
  }
  if (type === 'error') {
    messages.value.push({ role: 'assistant', content: `❌ ${data.message}` })
  }
}

async function handleStop() {
  if (!activeConvId.value) return
  try { await fetch(`/api/agent/${props.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, 80) + 'px'
}

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

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

<style scoped>
.chat-bar {
  flex-shrink: 0;
  border-top: 1px solid var(--border-primary, #e5e5e5);
  background: var(--bg-secondary, #fafafa);
  display: flex;
  flex-direction: column;
}

.bar-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 14px;
  cursor: pointer;
}
.bar-avatar { font-size: 16px; }
.bar-name { font-size: 12px; font-weight: 700; color: var(--text-secondary, #555); text-transform: uppercase; }
.context-chip {
  font-size: 11px;
  padding: 3px 8px;
  border-radius: 999px;
  background: var(--accent-soft, #e6f7f0);
  color: var(--accent, #10b981);
  max-width: 220px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.bar-input { flex: 1; display: flex; gap: 8px; align-items: flex-end; }
.msg-textarea {
  flex: 1;
  border: 1px solid var(--border-primary, #ddd);
  outline: none;
  background: var(--bg-input, #fff);
  color: var(--text-primary, #111);
  font-size: 13px;
  line-height: 1.5;
  resize: none;
  padding: 6px 10px;
  border-radius: 8px;
  max-height: 80px;
}
.msg-textarea:focus { border-color: var(--accent, #10b981); }
.send-btn {
  width: 32px; height: 32px; border-radius: 8px; border: none; font-size: 14px;
  cursor: pointer; flex-shrink: 0; background: var(--bg-input, #eee); color: var(--text-muted, #999);
}
.send-btn.ready { background: var(--accent, #10b981); color: #fff; }
.collapse-btn {
  border: none; background: none; cursor: pointer; font-size: 12px; color: var(--text-muted, #999);
  flex-shrink: 0;
}

.panel {
  border-top: 1px solid var(--border-primary, #eee);
  max-height: 45vh;
  display: flex;
  flex-direction: column;
}
.message-list { overflow-y: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 12px; }
.empty-chat { text-align: center; color: var(--text-muted, #999); font-size: 12px; padding: 12px 0; }

.msg-row { display: flex; gap: 8px; max-width: 80%; }
.msg-user { align-self: flex-end; flex-direction: row-reverse; }
.msg-assistant { align-self: flex-start; }
.msg-avatar { width: 24px; height: 24px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0; }
.avatar-user { background: var(--avatar-user-bg, #dbeafe); }
.avatar-assistant { background: var(--avatar-assistant-bg, #d1fae5); }
.msg-body { flex: 1; min-width: 0; }
.msg-content { padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; }
.msg-user .msg-content { background: var(--msg-user-bg, #eff6ff); border: 1px solid var(--msg-user-border, #dbeafe); color: var(--text-primary, #111); }
.msg-assistant .msg-content { background: var(--msg-assistant-bg, #f0fdf4); border: 1px solid var(--msg-assistant-border, #d1fae5); color: var(--text-secondary, #333); }
.msg-content :deep(pre) { background: var(--bg-code, #f5f5f5); padding: 8px; border-radius: 6px; overflow-x: auto; font-size: 11px; margin: 4px 0; }
.msg-content :deep(p) { margin: 0 0 4px; }
.msg-content :deep(p:last-child) { margin: 0; }

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