Files
OnceLove-New/OnceLove/oncelove-graphrag/frontend/src/components/GraphQaPanel.vue
KOSHM-Pig 1f087599c5 feat(graphrag): 新增多轮检索流式问答与用户管理界面
- 新增多轮 GraphRAG 检索功能,支持流式进度输出(SSE)
- 新增用户管理界面,可查看所有用户图谱统计并快速导航
- 新增多 Agent 任务拆解与执行服务,支持复杂任务协作处理
- 改进 embedding 和 rerank 服务的容错机制,支持备用模型和端点
- 更新前端样式遵循 Acmetone 设计规范,优化视觉一致性
- 新增流式分析接口,支持并行处理和专家评审选项
2026-03-24 12:04:28 +08:00

856 lines
20 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="qa-wrap">
<div class="ambient-glow"></div>
<div class="panel-card">
<div class="panel-head">
<h3>图谱问答</h3>
<span class="user-tag">userId: {{ userId }}</span>
</div>
<div class="qa-opts">
<label>
轮次
<input v-model.number="maxRounds" type="number" min="2" max="5" class="opt-num" />
</label>
<label>
每轮 TopK
<input v-model.number="topK" type="number" min="2" max="30" class="opt-num" />
</label>
<label>
检索模式
<select v-model="retrievalMode" class="opt-select">
<option value="hybrid">混合检索</option>
<option value="vector">向量检索</option>
<option value="graph">图谱检索</option>
</select>
</label>
</div>
<div class="meta-tip">
<span>重排序模型{{ rerankModelText }}</span>
<span>当前检索{{ retrievalModeText }}</span>
</div>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
</div>
<div class="chat-card">
<h4>AI 对话</h4>
<div ref="chatListRef" class="chat-list">
<div v-for="item in chatMessages" :key="item.id" class="chat-item" :class="item.role">
<p class="chat-role">{{ item.role === 'user' ? '你' : 'AI' }}</p>
<details v-if="item.role === 'assistant' && hasThinkingData" class="thinking-block" :open="item.pending">
<summary>深度思考 / 工具调用</summary>
<p v-for="line in thinkingLines" :key="line.key" class="thinking-line">{{ line.text }}</p>
</details>
<p class="chat-text">{{ item.pending ? '思考中...' : item.text }}</p>
</div>
<p v-if="!chatMessages.length" class="empty">等待提问</p>
</div>
<div class="chat-compose">
<input
v-model.trim="question"
class="qa-input"
placeholder="输入问题,例如:我和李文旗现在关系怎么样?"
:disabled="loading"
@keyup.enter="runQuery"
/>
<button class="btn-container tech-btn dark" :disabled="!question || loading" @click="runQuery">
<span class="btn-label">{{ loading ? '处理中...' : '发送' }}</span>
<span class="btn-arrow"></span>
<span class="corner top-left"></span>
<span class="corner top-right"></span>
<span class="corner bottom-left"></span>
<span class="corner bottom-right"></span>
</button>
</div>
</div>
<div class="console-card">
<h4>Console</h4>
<div class="console-shell">
<div class="console-head">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
<span class="console-title">graphrag-console</span>
</div>
<div ref="consoleBodyRef" class="console-body">
<p v-for="line in consoleLines" :key="line.key" class="console-line" :class="line.status">
<span class="console-prefix">[{{ line.statusLabel }}]</span>
<span class="console-text">{{ line.text }}</span>
</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
const props = defineProps({
userId: {
type: String,
default: 'default'
}
})
const question = ref('')
const loading = ref(false)
const errorMsg = ref('')
const maxRounds = ref(3)
const topK = ref(8)
const retrievalMode = ref('hybrid')
const resultMeta = ref(null)
const chatMessages = ref([])
const rounds = ref([])
const queryPlan = ref([])
const graphProbe = ref({
keywordCount: 0,
entityCount: 0,
neighborCount: 0,
eventCount: 0,
entities: []
})
const consoleLines = ref([])
const lineSeed = ref(0)
const chatListRef = ref(null)
const consoleBodyRef = ref(null)
const rerankModelText = computed(() => {
const rerank = resultMeta.value?.rerank
if (!rerank?.configured) return '未配置'
if (!rerank?.enabled) return `${rerank.model || '-'}(不可用)`
return rerank.model || '-'
})
const retrievalModeText = computed(() => resultMeta.value?.retrieval_mode_requested || retrievalMode.value)
const hasThinkingData = computed(
() => queryPlan.value.length > 0 || rounds.value.length > 0 || graphProbe.value.entityCount > 0 || graphProbe.value.keywordCount > 0
)
const thinkingLines = computed(() => {
const lines = []
if (queryPlan.value.length) {
lines.push(...queryPlan.value.map((q, idx) => ({ key: `plan_${idx}`, text: `问题拆解 #${idx + 1}${q}` })))
}
if (graphProbe.value.keywordCount || graphProbe.value.entityCount) {
lines.push({
key: 'probe_summary',
text: `图谱探查:关键词 ${graphProbe.value.keywordCount} · 实体 ${graphProbe.value.entityCount} · 邻居 ${graphProbe.value.neighborCount} · 事件 ${graphProbe.value.eventCount}`
})
lines.push(
...graphProbe.value.entities.map((item) => ({
key: `probe_${item.id}`,
text: `命中实体:${item.name}(${item.type}) · 邻居 ${item.neighborCount} · 事件 ${item.eventCount}`
}))
)
}
if (rounds.value.length) {
lines.push(
...rounds.value.flatMap((r, idx) => [
{
key: `round_${idx}`,
text: `${r.round} 轮检索:${r.subQuery || '-'} · 模式 ${r.retrievalMode || '-'} · 片段 ${r.chunkCount || 0} · 时间线 ${r.timelineCount || 0}`
},
...(r.answer ? [{ key: `round_answer_${idx}`, text: `阶段结论:${r.answer}` }] : [])
])
)
}
return lines
})
const stageLabel = {
pipeline_start: '任务初始化',
graph_probe: '图谱探查',
query_plan: '问题拆解',
knowledge_retrieval: '知识检索',
relation_organize: '关系整理',
evidence_synthesis: '证据汇总',
final_review: '终审判别'
}
const stageStatusText = (status) => {
if (status === 'running') return '执行中'
if (status === 'done') return '已完成'
if (status === 'error') return '失败'
return '等待中'
}
const pushConsole = (status, text) => {
lineSeed.value += 1
consoleLines.value = [...consoleLines.value, { key: lineSeed.value, status, statusLabel: stageStatusText(status), text }]
}
const scrollToBottom = (targetRef) => {
const el = targetRef?.value
if (!el) return
el.scrollTop = el.scrollHeight
}
watch(
() => chatMessages.value.length,
async () => {
await nextTick()
scrollToBottom(chatListRef)
}
)
watch(
() => consoleLines.value.length,
async () => {
await nextTick()
scrollToBottom(consoleBodyRef)
}
)
watch(
() => `${queryPlan.value.length}_${rounds.value.length}_${graphProbe.value.entityCount}_${graphProbe.value.keywordCount}`,
async () => {
await nextTick()
scrollToBottom(chatListRef)
}
)
const resetRunState = () => {
errorMsg.value = ''
resultMeta.value = null
rounds.value = []
queryPlan.value = []
graphProbe.value = {
keywordCount: 0,
entityCount: 0,
neighborCount: 0,
eventCount: 0,
entities: []
}
consoleLines.value = []
}
const updateAssistantMessage = (id, text, pending = false) => {
const idx = chatMessages.value.findIndex((item) => item.id === id)
if (idx < 0) return
const current = chatMessages.value[idx]
const next = { ...current, text, pending }
chatMessages.value.splice(idx, 1, next)
}
const handleProgress = (data) => {
if (!data?.stage) return
const label = stageLabel[data.stage] || data.stage
if (data.rerank || data.retrieval_mode_requested) {
resultMeta.value = {
...(resultMeta.value || {}),
...(data.rerank ? { rerank: data.rerank } : {}),
...(data.retrieval_mode_requested ? { retrieval_mode_requested: data.retrieval_mode_requested } : {})
}
}
if (data.stage === 'query_plan') {
queryPlan.value = Array.isArray(data.queries) ? data.queries : []
pushConsole(data.status || 'done', `${label} · 子问题 ${queryPlan.value.length}`)
return
}
if (data.stage === 'graph_probe') {
graphProbe.value = {
keywordCount: Number(data.keywordCount || 0),
entityCount: Number(data.entityCount || 0),
neighborCount: Number(data.neighborCount || 0),
eventCount: Number(data.eventCount || 0),
entities: Array.isArray(data.entities) ? data.entities : []
}
pushConsole(data.status || 'done', `${label} · 关键词 ${graphProbe.value.keywordCount} · 实体 ${graphProbe.value.entityCount}`)
return
}
if (data.stage === 'knowledge_retrieval') {
if (data.status === 'done') {
rounds.value = [...rounds.value.filter((r) => r.round !== data.round), data].sort((a, b) => a.round - b.round)
pushConsole('done', `${label} · 第 ${data.round} 轮完成(片段 ${data.chunkCount || 0} · 时间线 ${data.timelineCount || 0}`)
return
}
pushConsole(data.status || 'running', `${label} · 第 ${data.round}/${data.totalRounds} 轮执行中`)
return
}
if (data.stage === 'relation_organize') {
pushConsole(data.status || 'done', `${label} · 关系线索 ${data.relationHints?.length || 0}`)
return
}
if (data.stage === 'evidence_synthesis') {
pushConsole(data.status || 'done', `${label} · 证据 ${data.evidenceCount || 0}`)
return
}
if (data.stage === 'pipeline_start') {
pushConsole(data.status || 'done', `${label} · 问题:${data.question || '-'}`)
return
}
if (data.stage === 'final_review') {
pushConsole(data.status || 'done', `${label} · 已完成`)
}
}
const processSseBlock = (block) => {
const lines = block.split(/\r?\n/)
let event = 'message'
const dataLines = []
for (const line of lines) {
const normalized = line.trimStart()
if (normalized.startsWith('event:')) event = normalized.slice(6).trim()
if (normalized.startsWith('data:')) dataLines.push(normalized.slice(5).trim())
}
if (!dataLines.length) return null
const raw = dataLines.join('\n')
let data = null
try {
data = JSON.parse(raw)
} catch {
return null
}
if (event === 'progress') {
handleProgress(data)
return null
}
if (event === 'done') {
if (Array.isArray(data?.rounds)) rounds.value = data.rounds
if (data?.meta) resultMeta.value = data.meta
pushConsole('done', '流式响应结束')
return data
}
if (event === 'error') {
throw new Error(data?.message || '流式请求失败')
}
return null
}
const runQuery = async () => {
if (!question.value || loading.value) return
loading.value = true
resetRunState()
const userText = question.value
const userMessageId = `user_${Date.now()}`
const assistantMessageId = `assistant_${Date.now()}`
chatMessages.value.push({ id: userMessageId, role: 'user', text: userText, pending: false })
chatMessages.value.push({ id: assistantMessageId, role: 'assistant', text: '', pending: true })
pushConsole('running', '请求已发送')
try {
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
const res = await fetch(`${apiBase}/query/graphrag/multi/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: props.userId || 'default',
query_text: userText,
max_rounds: maxRounds.value,
top_k: topK.value,
retrieval_mode: retrievalMode.value
})
})
if (!res.ok || !res.body) {
const text = await res.text()
throw new Error(text || `请求失败(${res.status})`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
let donePayload = null
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split(/\r?\n\r?\n/)
buffer = parts.pop() || ''
for (const part of parts) {
if (!part.trim()) continue
const payload = processSseBlock(part)
if (payload) donePayload = payload
}
}
if (buffer.trim()) {
const payload = processSseBlock(buffer)
if (payload) donePayload = payload
}
const finalAnswer = donePayload?.final_review?.answer || donePayload?.final_answer || '未获取到最终回答'
updateAssistantMessage(assistantMessageId, finalAnswer, false)
} catch (e) {
const text = `问答失败:${e.message}`
errorMsg.value = text
pushConsole('error', text)
updateAssistantMessage(assistantMessageId, text, false)
} finally {
loading.value = false
question.value = ''
}
}
</script>
<style scoped>
.qa-wrap {
position: relative;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) clamp(170px, 30vh, 300px);
gap: 10px;
height: calc(100vh - 132px);
max-height: calc(100vh - 132px);
min-height: 0;
overflow: hidden;
color: #111111;
font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'PingFang SC', sans-serif);
}
.ambient-glow {
position: absolute;
top: -220px;
left: -6%;
width: 112%;
height: 360px;
background: radial-gradient(circle at center, rgba(17, 17, 17, 0.12), rgba(17, 17, 17, 0) 66%);
pointer-events: none;
z-index: 0;
}
.panel-card,
.chat-card,
.console-card {
position: relative;
z-index: 1;
background: #ffffff;
border: 1px solid #dcdcdc;
border-radius: 10px;
padding: 16px;
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.04);
}
.panel-card::before,
.panel-card::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
border-color: #111111;
pointer-events: none;
}
.panel-card::before {
top: -1px;
left: -1px;
border-top: 1px solid #111111;
border-left: 1px solid #111111;
}
.panel-card::after {
right: -1px;
bottom: -1px;
border-right: 1px solid #111111;
border-bottom: 1px solid #111111;
}
.panel-card {
grid-row: 1 / 2;
}
.chat-card {
grid-row: 2 / 3;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.console-card {
grid-row: 3 / 4;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
padding: 12px;
background: #0f0f0f;
border-color: #2d2d2d;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.panel-head h3,
.chat-card h4,
.console-card h4 {
margin: 0;
font-size: 16px;
letter-spacing: 0.8px;
}
.user-tag {
font-size: 12px;
color: #333333;
background: #f8f8f8;
border: 1px solid #cfcfcf;
padding: 5px 10px;
border-radius: 999px;
letter-spacing: 0.4px;
}
.qa-input {
flex: 1;
border: 1px solid #cccccc;
border-radius: 8px;
padding: 10px 12px;
outline: none;
color: #111111;
background: #ffffff;
}
.qa-input:focus {
border-color: #111111;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.08);
}
.qa-opts {
display: flex;
gap: 14px;
margin-top: 12px;
color: #555555;
font-size: 13px;
}
.qa-opts label {
display: flex;
align-items: center;
gap: 6px;
}
.opt-num,
.opt-select {
border: 1px solid #cccccc;
border-radius: 8px;
padding: 5px 8px;
background: #ffffff;
color: #111111;
outline: none;
}
.opt-num {
width: 70px;
}
.meta-tip {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px;
color: #666666;
font-size: 12px;
}
.chat-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
min-height: 0;
flex: 1;
padding: 0 2px 12px;
}
.chat-item {
border: 1px solid #ebebeb;
border-radius: 10px;
padding: 12px 14px;
background: #ffffff;
max-width: min(920px, 96%);
width: fit-content;
}
.chat-item.assistant {
background: #fcfcfc;
align-self: flex-start;
}
.chat-item.user {
background: #f5f7fb;
border-color: #e7ebf4;
align-self: flex-end;
}
.chat-item.user .chat-role {
color: #667085;
}
.chat-role {
margin: 0;
color: #666666;
font-size: 12px;
}
.chat-text {
margin: 6px 0 0;
white-space: pre-wrap;
line-height: 1.6;
font-size: 14px;
}
.thinking-block {
margin: 6px 0 0;
border: 1px solid #e7e7e7;
border-radius: 8px;
background: #fafafa;
padding: 6px 8px;
}
.thinking-block summary {
cursor: pointer;
font-size: 12px;
color: #666666;
}
.thinking-line {
margin: 6px 0 0;
font-size: 12px;
color: #555555;
line-height: 1.5;
white-space: pre-wrap;
}
.chat-compose {
margin-top: 6px;
display: flex;
gap: 10px;
align-items: center;
border-top: 1px solid #e4e4e4;
padding-top: 10px;
position: sticky;
bottom: 0;
background: #ffffff;
z-index: 2;
}
.console-shell {
margin-top: 2px;
border: 1px solid #2d2d2d;
border-radius: 6px;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.console-head {
display: flex;
align-items: center;
gap: 6px;
background: #0f0f0f;
border-bottom: 1px solid #2e2e2e;
padding: 8px 10px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #8a8a8a;
}
.console-title {
margin-left: 6px;
font-size: 12px;
color: #dfdfdf;
letter-spacing: 0.8px;
}
.console-body {
display: flex;
flex-direction: column;
gap: 6px;
overflow: auto;
background: #111111;
padding: 10px;
min-height: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
}
.console-line {
margin: 0;
font-size: 12px;
line-height: 1.6;
display: flex;
gap: 8px;
color: #e8e8e8;
}
.console-prefix {
min-width: 46px;
color: #9d9d9d;
}
.console-line.running .console-prefix {
color: #ffffff;
}
.console-line.done .console-prefix {
color: #cbcbcb;
}
.console-line.error .console-prefix {
color: #f1f1f1;
}
.tool-block,
.tool-sub-block {
border: 1px solid #343434;
border-radius: 6px;
padding: 6px 8px;
background: #161616;
color: #d9d9d9;
}
.tool-sub-block {
margin-top: 6px;
}
.tool-block summary,
.tool-sub-block summary {
cursor: pointer;
font-size: 12px;
}
.tool-line {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.5;
color: #c7c7c7;
white-space: pre-wrap;
}
.error-msg {
margin-top: 8px;
color: #333333;
font-size: 13px;
border: 1px solid #cfcfcf;
padding: 7px 9px;
border-radius: 8px;
background: #f8f8f8;
}
.empty {
color: #888888;
font-size: 13px;
margin: 2px 0;
}
.btn-container {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 100px;
border: 1px solid #111111;
border-radius: 8px;
padding: 10px 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.tech-btn.dark {
background: #111111;
color: #ffffff;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.15);
}
.btn-label {
font-size: 13px;
letter-spacing: 1px;
}
.btn-arrow {
transition: transform 0.2s ease;
}
.btn-container:hover .btn-arrow {
transform: translateX(4px);
}
.corner {
position: absolute;
width: 8px;
height: 8px;
border-color: #ffffff;
transition: transform 0.2s ease;
}
.top-left {
top: 4px;
left: 4px;
border-top: 1px solid #ffffff;
border-left: 1px solid #ffffff;
}
.top-right {
top: 4px;
right: 4px;
border-top: 1px solid #ffffff;
border-right: 1px solid #ffffff;
}
.bottom-left {
bottom: 4px;
left: 4px;
border-bottom: 1px solid #ffffff;
border-left: 1px solid #ffffff;
}
.bottom-right {
right: 4px;
bottom: 4px;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
}
.btn-container:hover .top-left {
transform: translate(-2px, -2px);
}
.btn-container:hover .top-right {
transform: translate(2px, -2px);
}
.btn-container:hover .bottom-left {
transform: translate(-2px, 2px);
}
.btn-container:hover .bottom-right {
transform: translate(2px, 2px);
}
.btn-container:disabled {
cursor: not-allowed;
background: #666666;
border-color: #666666;
box-shadow: none;
}
.btn-container:disabled .corner {
border-color: #dddddd;
}
@media (max-width: 1100px) {
.qa-wrap {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr) clamp(160px, 36vh, 260px);
height: calc(100vh - 116px);
max-height: calc(100vh - 116px);
}
}
@media (max-width: 840px) {
.chat-compose {
flex-direction: column;
}
.qa-opts {
flex-wrap: wrap;
gap: 8px 12px;
}
}
</style>