Commit 2bee467f1abbdf4ea6b6798e1463d18840f097e9

Authored by yibo
1 parent a4b8b87b

feat(graph-console): 底部聊天条联动 CEO agent,自动带上当前 task/goal 上下文

graph-console 页面底部新增可展开/收起的聊天条(GraphChatBar.vue,仿 MasterChatDrawer.vue
写的自包含最小聊天组件),直接跟公司 ceo agent 对话,不用跳去 AgentChat 整页。会话持久化在
localStorage(按 companyId 区分),刷新页面自动拉历史消息恢复。

发消息时若当前选中的 task/goal 跟上次发送时不同,会在实际发给后端的文本前拼一行上下文说明,
agent 不用用户手动复述在看哪个任务;聊天气泡里展示的仍是用户输入的原文,不被这行说明污染;
上下文不变时不重复注入。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCd5WFjL1m31FaJXKx1j7Z
web/src/components/GraphChatBar.vue 0 → 100644
  1 +<template>
  2 + <div class="chat-bar" :class="{ expanded }">
  3 + <div class="bar-row" @click="!expanded && (expanded = true)">
  4 + <span class="bar-avatar">🤖</span>
  5 + <span class="bar-name">{{ agentId }}</span>
  6 + <span v-if="context" class="context-chip" :title="contextTooltip">🔗 {{ contextLabel }}</span>
  7 + <div class="bar-input" @click.stop>
  8 + <textarea
  9 + ref="inputRef"
  10 + v-model="inputText"
  11 + :disabled="sending"
  12 + placeholder="跟 CEO 聊两句…"
  13 + class="msg-textarea"
  14 + rows="1"
  15 + @keydown="handleKeydown"
  16 + @input="autoResize"
  17 + @focus="expanded = true"
  18 + ></textarea>
  19 + <button v-if="sending" class="send-btn stop-btn" title="停止" @click="handleStop">⏹</button>
  20 + <button v-else class="send-btn" :class="{ ready: inputText.trim() && !sending }" :disabled="!inputText.trim()" @click="handleSend">➤</button>
  21 + </div>
  22 + <button class="collapse-btn" :title="expanded ? '收起' : '展开'" @click.stop="expanded = !expanded">{{ expanded ? '▾' : '▴' }}</button>
  23 + </div>
  24 +
  25 + <div v-if="expanded" class="panel">
  26 + <div class="message-list" ref="messageListRef">
  27 + <div v-if="messages.length === 0 && !streaming" class="empty-chat">跟 CEO 聊两句吧,选中的任务/目标会自动带给它。</div>
  28 + <div v-for="(msg, idx) in messages" :key="idx" class="msg-row" :class="`msg-${msg.role}`">
  29 + <div class="msg-avatar" :class="`avatar-${msg.role}`">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
  30 + <div class="msg-body">
  31 + <div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
  32 + </div>
  33 + </div>
  34 + <div v-if="streaming" class="msg-row msg-assistant streaming">
  35 + <div class="msg-avatar avatar-assistant">🤖</div>
  36 + <div class="msg-body">
  37 + <div class="msg-content" v-html="renderMarkdown(streamContent)"></div>
  38 + <span class="typing-indicator"><span></span><span></span><span></span></span>
  39 + </div>
  40 + </div>
  41 + <div v-if="sending && thinkingText" class="thinking-row">
  42 + <div class="thinking-pulse"></div>
  43 + <span class="thinking-text">{{ thinkingText }}</span>
  44 + </div>
  45 + </div>
  46 + </div>
  47 + </div>
  48 +</template>
  49 +
  50 +<script setup>
  51 +import { ref, computed, nextTick, watch, onMounted } from 'vue'
  52 +import { marked } from 'marked'
  53 +
  54 +const props = defineProps({
  55 + companyId: { type: String, required: true },
  56 + agentId: { type: String, default: 'ceo' },
  57 + context: { type: Object, default: null }, // { taskId, taskTitle, taskStatus, goalId, goalTitle }
  58 +})
  59 +
  60 +const expanded = ref(false)
  61 +const activeConvId = ref('')
  62 +const CONV_KEY = `lemonboss.graphChatBar.conv.${props.companyId}`
  63 +
  64 +const messages = ref([])
  65 +const inputText = ref('')
  66 +const sending = ref(false)
  67 +const streaming = ref(false)
  68 +const streamContent = ref('')
  69 +const thinkingText = ref('')
  70 +const messageListRef = ref(null)
  71 +const inputRef = ref(null)
  72 +
  73 +const lastSentContextKey = ref('')
  74 +
  75 +const contextLabel = computed(() => {
  76 + const c = props.context
  77 + if (!c) return ''
  78 + return `${c.taskTitle || c.taskId}`
  79 +})
  80 +const contextTooltip = computed(() => {
  81 + const c = props.context
  82 + if (!c) return ''
  83 + return `任务 ${c.taskId}《${c.taskTitle}》(${c.taskStatus}) · 目标 ${c.goalId}《${c.goalTitle}》`
  84 +})
  85 +
  86 +function contextKey(c) {
  87 + if (!c) return ''
  88 + return `${c.taskId || ''}|${c.goalId || ''}`
  89 +}
  90 +
  91 +function renderMarkdown(text) {
  92 + try { return marked.parse(text || '', { breaks: true, gfm: true }) }
  93 + catch { return (text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') }
  94 +}
  95 +
  96 +async function hydrateHistory() {
  97 + if (!activeConvId.value) return
  98 + try {
  99 + const r = await fetch(`/api/agent/${props.agentId}/conversations/${activeConvId.value}`)
  100 + if (!r.ok) return
  101 + const j = await r.json()
  102 + messages.value = (j.messages || []).map(m => ({ role: m.role, content: m.content, timestamp: m.timestamp }))
  103 + } catch (e) { /* 历史拉取失败不影响继续新对话 */ }
  104 +}
  105 +
  106 +onMounted(() => {
  107 + activeConvId.value = localStorage.getItem(CONV_KEY) || ''
  108 + hydrateHistory()
  109 +})
  110 +
  111 +async function handleSend() {
  112 + const text = inputText.value.trim()
  113 + if (!text || sending.value) return
  114 +
  115 + const key = contextKey(props.context)
  116 + let payload = text
  117 + if (props.context && key !== lastSentContextKey.value) {
  118 + payload = `[当前上下文] 任务 ${props.context.taskId}《${props.context.taskTitle}》(状态: ${props.context.taskStatus}) · 目标 ${props.context.goalId}《${props.context.goalTitle}》\n\n${text}`
  119 + lastSentContextKey.value = key
  120 + }
  121 +
  122 + messages.value.push({ role: 'user', content: text, timestamp: new Date().toISOString() })
  123 + inputText.value = ''
  124 + if (inputRef.value) inputRef.value.style.height = 'auto'
  125 +
  126 + sending.value = true
  127 + streaming.value = false
  128 + streamContent.value = ''
  129 + thinkingText.value = '🤖 思考中...'
  130 + scrollToBottom()
  131 +
  132 + try {
  133 + const r = await fetch(`/api/agent/${props.agentId}/chat`, {
  134 + method: 'POST', headers: { 'Content-Type': 'application/json' },
  135 + body: JSON.stringify({ message: payload, conversationId: activeConvId.value }),
  136 + })
  137 + if (!r.ok) {
  138 + const errBody = await r.json().catch(() => ({}))
  139 + messages.value.push({ role: 'assistant', content: `❌ 错误 (${r.status}): ${errBody.error || r.statusText}` })
  140 + return
  141 + }
  142 + const reader = r.body.getReader()
  143 + const decoder = new TextDecoder()
  144 + let buffer = '', lastEventType = ''
  145 +
  146 + while (true) {
  147 + const { done, value } = await reader.read()
  148 + if (done) break
  149 + buffer += decoder.decode(value, { stream: true })
  150 + const lines = buffer.split('\n')
  151 + buffer = lines.pop() || ''
  152 + for (const line of lines) {
  153 + if (line.startsWith('event: ')) lastEventType = line.slice(7)
  154 + else if (line.startsWith('data: ')) {
  155 + try { handleEvent(lastEventType, JSON.parse(line.slice(6))) } catch {}
  156 + }
  157 + }
  158 + }
  159 +
  160 + if (streamContent.value) {
  161 + messages.value.push({ role: 'assistant', content: streamContent.value, timestamp: new Date().toISOString() })
  162 + }
  163 + } catch (err) {
  164 + messages.value.push({ role: 'assistant', content: `❌ 错误: ${err.message}` })
  165 + } finally {
  166 + sending.value = false; streaming.value = false; streamContent.value = ''; thinkingText.value = ''
  167 + }
  168 +}
  169 +
  170 +function handleEvent(type, data) {
  171 + if (type === 'session' && data.conversationId && !activeConvId.value) {
  172 + activeConvId.value = data.conversationId
  173 + localStorage.setItem(CONV_KEY, activeConvId.value)
  174 + }
  175 + if (type === 'thinking') {
  176 + thinkingText.value = data.message || '思考中...'
  177 + }
  178 + if (type === 'queued') {
  179 + const waitSec = Math.round((data.waitBudgetMs || 0) / 1000)
  180 + thinkingText.value = `⏳ 排队等待中,最多等待 ${waitSec} 秒`
  181 + }
  182 + if (type === 'queue_timeout') {
  183 + messages.value.push({ role: 'assistant', content: `⏳ ${data.error || 'Agent 排队等待超时,请稍后重试'}` })
  184 + }
  185 + if (type === 'agent_output') {
  186 + streaming.value = true
  187 + streamContent.value += (data.message || '')
  188 + scrollToBottom()
  189 + }
  190 + if (type === 'tool_call') {
  191 + thinkingText.value = data.message || `🔧 ${data.tool}`
  192 + }
  193 + if (type === 'error') {
  194 + messages.value.push({ role: 'assistant', content: `❌ ${data.message}` })
  195 + }
  196 +}
  197 +
  198 +async function handleStop() {
  199 + if (!activeConvId.value) return
  200 + try { await fetch(`/api/agent/${props.agentId}/chat/${activeConvId.value}/stop`, { method: 'POST' }) } catch (e) {}
  201 + sending.value = false; streaming.value = false
  202 + messages.value.push({ role: 'assistant', content: `*[已终止]*` })
  203 + scrollToBottom()
  204 +}
  205 +
  206 +function handleKeydown(e) {
  207 + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
  208 + e.preventDefault(); handleSend()
  209 + }
  210 +}
  211 +
  212 +function autoResize() {
  213 + const el = inputRef.value; if (!el) return
  214 + el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 80) + 'px'
  215 +}
  216 +
  217 +function scrollToBottom() {
  218 + nextTick(() => {
  219 + if (messageListRef.value) messageListRef.value.scrollTop = messageListRef.value.scrollHeight
  220 + })
  221 +}
  222 +
  223 +watch([messages, streamContent, thinkingText], () => scrollToBottom(), { deep: true })
  224 +</script>
  225 +
  226 +<style scoped>
  227 +.chat-bar {
  228 + flex-shrink: 0;
  229 + border-top: 1px solid var(--border-primary, #e5e5e5);
  230 + background: var(--bg-secondary, #fafafa);
  231 + display: flex;
  232 + flex-direction: column;
  233 +}
  234 +
  235 +.bar-row {
  236 + display: flex;
  237 + align-items: center;
  238 + gap: 10px;
  239 + padding: 8px 14px;
  240 + cursor: pointer;
  241 +}
  242 +.bar-avatar { font-size: 16px; }
  243 +.bar-name { font-size: 12px; font-weight: 700; color: var(--text-secondary, #555); text-transform: uppercase; }
  244 +.context-chip {
  245 + font-size: 11px;
  246 + padding: 3px 8px;
  247 + border-radius: 999px;
  248 + background: var(--accent-soft, #e6f7f0);
  249 + color: var(--accent, #10b981);
  250 + max-width: 220px;
  251 + overflow: hidden;
  252 + text-overflow: ellipsis;
  253 + white-space: nowrap;
  254 +}
  255 +.bar-input { flex: 1; display: flex; gap: 8px; align-items: flex-end; }
  256 +.msg-textarea {
  257 + flex: 1;
  258 + border: 1px solid var(--border-primary, #ddd);
  259 + outline: none;
  260 + background: var(--bg-input, #fff);
  261 + color: var(--text-primary, #111);
  262 + font-size: 13px;
  263 + line-height: 1.5;
  264 + resize: none;
  265 + padding: 6px 10px;
  266 + border-radius: 8px;
  267 + max-height: 80px;
  268 +}
  269 +.msg-textarea:focus { border-color: var(--accent, #10b981); }
  270 +.send-btn {
  271 + width: 32px; height: 32px; border-radius: 8px; border: none; font-size: 14px;
  272 + cursor: pointer; flex-shrink: 0; background: var(--bg-input, #eee); color: var(--text-muted, #999);
  273 +}
  274 +.send-btn.ready { background: var(--accent, #10b981); color: #fff; }
  275 +.collapse-btn {
  276 + border: none; background: none; cursor: pointer; font-size: 12px; color: var(--text-muted, #999);
  277 + flex-shrink: 0;
  278 +}
  279 +
  280 +.panel {
  281 + border-top: 1px solid var(--border-primary, #eee);
  282 + max-height: 45vh;
  283 + display: flex;
  284 + flex-direction: column;
  285 +}
  286 +.message-list { overflow-y: auto; padding: 12px 16px; display: flex; flex-direction: column; gap: 12px; }
  287 +.empty-chat { text-align: center; color: var(--text-muted, #999); font-size: 12px; padding: 12px 0; }
  288 +
  289 +.msg-row { display: flex; gap: 8px; max-width: 80%; }
  290 +.msg-user { align-self: flex-end; flex-direction: row-reverse; }
  291 +.msg-assistant { align-self: flex-start; }
  292 +.msg-avatar { width: 24px; height: 24px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; flex-shrink: 0; }
  293 +.avatar-user { background: var(--avatar-user-bg, #dbeafe); }
  294 +.avatar-assistant { background: var(--avatar-assistant-bg, #d1fae5); }
  295 +.msg-body { flex: 1; min-width: 0; }
  296 +.msg-content { padding: 8px 12px; border-radius: 10px; font-size: 13px; line-height: 1.5; }
  297 +.msg-user .msg-content { background: var(--msg-user-bg, #eff6ff); border: 1px solid var(--msg-user-border, #dbeafe); color: var(--text-primary, #111); }
  298 +.msg-assistant .msg-content { background: var(--msg-assistant-bg, #f0fdf4); border: 1px solid var(--msg-assistant-border, #d1fae5); color: var(--text-secondary, #333); }
  299 +.msg-content :deep(pre) { background: var(--bg-code, #f5f5f5); padding: 8px; border-radius: 6px; overflow-x: auto; font-size: 11px; margin: 4px 0; }
  300 +.msg-content :deep(p) { margin: 0 0 4px; }
  301 +.msg-content :deep(p:last-child) { margin: 0; }
  302 +
  303 +.thinking-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
  304 +.thinking-pulse { width: 6px; height: 6px; border-radius: 50%; background: var(--accent, #10b981); animation: glow 1.5s infinite; }
  305 +@keyframes glow { 0%,100%{opacity:1} 50%{opacity:0.3} }
  306 +.thinking-text { font-size: 11px; color: var(--text-muted, #999); font-family: monospace; }
  307 +.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; }
  308 +@keyframes bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-4px)} }
  309 +</style>
... ...
web/src/views/GraphConsole.vue
... ... @@ -171,6 +171,8 @@
171 171 />
172 172 </aside>
173 173 </div>
  174 +
  175 + <GraphChatBar :company-id="companyId" :context="chatContext" />
174 176 </div>
175 177 </template>
176 178  
... ... @@ -183,6 +185,7 @@ import NodeDetailPanel from &#39;@/components/graph-console/NodeDetailPanel.vue&#39;;
183 185 import ReplanOrgGraphButton from '@/components/graph-console/ReplanOrgGraphButton.vue';
184 186 import GoalTaskPane from '@/components/graph-console/GoalTaskPane.vue';
185 187 import TaskFilesPanel from '@/components/graph-console/TaskFilesPanel.vue';
  188 +import GraphChatBar from '@/components/GraphChatBar.vue';
186 189 import {
187 190 findTask, pickDefaultSelection, runStateToTaskPatch, hasStartedRun, pendingRunHint,
188 191 } from '@/components/graph-console/goal-task-model.js';
... ... @@ -263,6 +266,19 @@ const taskProjectRoot = computed(() =&gt; rootState.value.projectRoot ?? midState.v
263 266  
264 267 const selectedTask = computed(() => (findTask(goals.value, selectedTaskId.value) || {}).task || null);
265 268  
  269 +// 底部聊天条要带给 agent 的当前上下文(GraphChatBar 消费)
  270 +const chatContext = computed(() => {
  271 + const hit = findTask(goals.value, selectedTaskId.value);
  272 + if (!hit) return null;
  273 + return {
  274 + taskId: hit.task.taskId,
  275 + taskTitle: hit.task.title,
  276 + taskStatus: hit.task.status,
  277 + goalId: hit.goal.goalId,
  278 + goalTitle: hit.goal.title,
  279 + };
  280 +});
  281 +
266 282 /**
267 283 * **左栏画哪张图,由选中的 task 决定**(P3C §七.3 / §一.A)。
268 284 *
... ...