feat: ltm and kb
This commit is contained in:
@@ -59,7 +59,24 @@ const MainRoutes = {
|
||||
{
|
||||
name: 'Alkaid',
|
||||
path: '/alkaid',
|
||||
component: () => import('@/views/AlkaidPage.vue')
|
||||
component: () => import('@/views/AlkaidPage.vue'),
|
||||
children: [
|
||||
{
|
||||
path: 'knowledge-base',
|
||||
name: 'KnowledgeBase',
|
||||
component: () => import('@/views/alkaid/KnowledgeBase.vue')
|
||||
},
|
||||
{
|
||||
path: 'long-term-memory',
|
||||
name: 'LongTermMemory',
|
||||
component: () => import('@/views/alkaid/LongTermMemory.vue')
|
||||
},
|
||||
{
|
||||
path: 'other',
|
||||
name: 'OtherFeatures',
|
||||
component: () => import('@/views/alkaid/Other.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Chat',
|
||||
|
||||
@@ -1,435 +1,63 @@
|
||||
<script setup>
|
||||
// 在较庞大的图下,d3 的性能不如 sigma.js 渲染库,因此我们优先使用 sigma.js 来渲染图。
|
||||
import * as d3 from "d3"; // npm install d3
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<v-card style="height: 100%; width: 100%;">
|
||||
<v-card-text class="pa-4" style="height: 100%;">
|
||||
<v-container fluid class="d-flex flex-column" style="height: 100%;">
|
||||
<div style="margin-bottom: 32px;">
|
||||
<h1 class="gradient-text">The Alkaid Project.</h1>
|
||||
<small style="color: #a3a3a3;">AstrBot 实验性项目</small>
|
||||
<small style="color: #a3a3a3;">AstrBot Alpha 项目</small>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||||
<v-btn size="large" :variant="activeTab === 'long-term-memory' ? 'flat' : 'tonal'"
|
||||
:color="activeTab === 'long-term-memory' ? '#9b72cb' : ''" rounded="lg"
|
||||
@click="activeTab = 'long-term-memory'">
|
||||
<v-btn size="large" :variant="isActive('knowledge-base') ? 'flat' : 'tonal'"
|
||||
:color="isActive('knowledge-base') ? '#9b72cb' : ''" rounded="lg"
|
||||
@click="navigateTo('knowledge-base')">
|
||||
<v-icon start>mdi-text-box-search</v-icon>
|
||||
知识库
|
||||
</v-btn>
|
||||
<v-btn size="large" :variant="isActive('long-term-memory') ? 'flat' : 'tonal'"
|
||||
:color="isActive('long-term-memory') ? '#9b72cb' : ''" rounded="lg"
|
||||
@click="navigateTo('long-term-memory')">
|
||||
<v-icon start>mdi-dots-hexagon</v-icon>
|
||||
长期记忆层
|
||||
</v-btn>
|
||||
<v-btn size="large" :variant="activeTab === 'other' ? 'flat' : 'tonal'"
|
||||
:color="activeTab === 'other' ? '#9b72cb' : ''" rounded="lg" @click="activeTab = 'other'">
|
||||
<v-icon start>mdi-dots-horizontal</v-icon>
|
||||
其他
|
||||
<v-btn size="large" :variant="isActive('other') ? 'flat' : 'tonal'"
|
||||
:color="isActive('other') ? '#9b72cb' : ''" rounded="lg"
|
||||
@click="navigateTo('other')">
|
||||
<v-icon start>mdi-tools</v-icon>
|
||||
...
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'long-term-memory'" id="long-term-memory" class="flex-grow-1"
|
||||
style="display: flex; flex-direction: row;">
|
||||
<div id="graph-container" style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px;">
|
||||
</div>
|
||||
<div id="graph-control-panel"
|
||||
style="min-width: 450px; border: 1px solid #eee; border-radius: 8px; padding: 16px; margin-left: 16px;">
|
||||
<div>
|
||||
<span style="color: #333333;">可视化</span>
|
||||
<div style="margin-top: 8px;">
|
||||
<v-autocomplete v-model="searchUserId" :items="userIdList" variant="outlined"
|
||||
label="筛选用户 ID"></v-autocomplete>
|
||||
<v-btn color="primary" @click="onNodeSelect" variant="tonal" style="margin-top: 8px;">
|
||||
<v-icon start>mdi-magnify</v-icon>
|
||||
筛选
|
||||
</v-btn>
|
||||
<v-btn color="secondary" @click="resetFilter" variant="tonal"
|
||||
style="margin-top: 8px; margin-left: 8px;">
|
||||
<v-icon start>mdi-filter-remove</v-icon>
|
||||
重置筛选
|
||||
</v-btn>
|
||||
</div>
|
||||
<div style="margin-top: 16px;">
|
||||
<v-btn color="primary" @click="refreshGraph" variant="tonal">
|
||||
<v-icon start>mdi-refresh</v-icon>
|
||||
刷新图形
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-4"></v-divider>
|
||||
|
||||
<div v-if="selectedNode" class="mt-4">
|
||||
<h3>节点详情</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div v-if="selectedNode.id">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">ID:</span>
|
||||
<span>{{ selectedNode.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode._label">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">类型:</span>
|
||||
<span>{{ selectedNode._label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.name">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">名称:</span>
|
||||
<span>{{ selectedNode.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.user_id">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">用户ID:</span>
|
||||
<span>{{ selectedNode.user_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.ts">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">时间戳:</span>
|
||||
<span>{{ selectedNode.ts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.type">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">类型:</span>
|
||||
<span>{{ selectedNode.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-if="graphStats" class="mt-4">
|
||||
<h3>图形统计</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">节点数:</span>
|
||||
<span>{{ graphStats.nodeCount }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">边数:</span>
|
||||
<span>{{ graphStats.edgeCount }}</span>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sub-view" class="flex-grow-1" style="max-height: 100%;">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'other'" class="flex-grow-1" style="display: flex; flex-direction: column;">
|
||||
<div class="d-flex align-center justify-center"
|
||||
style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px;">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-tools</v-icon>
|
||||
<p class="text-h6 text-grey ml-4">功能开发中</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
export default {
|
||||
name: 'AlkaidPage',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart
|
||||
},
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
simulation: null,
|
||||
svg: null,
|
||||
zoom: null,
|
||||
activeTab: 'long-term-memory',
|
||||
node_data: [],
|
||||
edge_data: [],
|
||||
nodes: [],
|
||||
links: [],
|
||||
searchUserId: null,
|
||||
userIdList: [],
|
||||
selectedNode: null,
|
||||
graphStats: null,
|
||||
nodeColors: {
|
||||
'PhaseNode': '#4CAF50', // 绿色
|
||||
'PassageNode': '#2196F3', // 蓝色
|
||||
'FactNode': '#FF9800', // 橙色
|
||||
'default': '#9C27B0' // 紫色作为默认
|
||||
},
|
||||
edgeColors: {
|
||||
'_include_': '#607D8B',
|
||||
'_related_': '#9E9E9E',
|
||||
'default': '#BDBDBD'
|
||||
},
|
||||
isLoading: false
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
navigateTo(tab) {
|
||||
this.$router.push(`/alkaid/${tab}`);
|
||||
},
|
||||
isActive(tab) {
|
||||
return this.$route.path.includes(`/alkaid/${tab}`);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initD3Graph();
|
||||
this.ltmGetGraph();
|
||||
this.ltmGetUserIds();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.simulation) {
|
||||
this.simulation.stop();
|
||||
// 如果在根路径 /alkaid,默认跳转到知识库页面
|
||||
if (this.$route.path === '/alkaid') {
|
||||
this.navigateTo('knowledge-base');
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
activeTab(newVal) {
|
||||
if (newVal === 'long-term-memory') {
|
||||
this.$nextTick(() => {
|
||||
if (!this.svg) {
|
||||
this.initD3Graph();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (this.simulation) {
|
||||
this.simulation.stop();
|
||||
this.simulation = null;
|
||||
}
|
||||
if (this.svg) {
|
||||
d3.select("#graph-container svg").remove();
|
||||
this.svg = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
ltmGetGraph(userId = null) {
|
||||
this.isLoading = true;
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
|
||||
axios.get('/api/plug/alkaid/ltm/graph', { params })
|
||||
.then(response => {
|
||||
let nodesRaw = response.data.data.nodes;
|
||||
let edgesRaw = response.data.data.edges;
|
||||
|
||||
this.node_data = nodesRaw;
|
||||
this.edge_data = edgesRaw;
|
||||
|
||||
// 转换为D3所需的数据格式
|
||||
this.nodes = nodesRaw.map(node => {
|
||||
const nodeId = node[0];
|
||||
const nodeData = node[1];
|
||||
const nodeType = nodeData._label || 'default';
|
||||
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
|
||||
|
||||
return {
|
||||
id: nodeId,
|
||||
label: nodeData.name || nodeId.split('_')[0],
|
||||
color: color,
|
||||
originalData: nodeData
|
||||
};
|
||||
});
|
||||
|
||||
this.links = edgesRaw.map(edge => {
|
||||
const sourceId = edge[0];
|
||||
const targetId = edge[1];
|
||||
const edgeData = edge[2];
|
||||
const relationType = edgeData.relation_type || 'default';
|
||||
const color = this.edgeColors[relationType] || this.edgeColors['default'];
|
||||
|
||||
return {
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
color: color,
|
||||
originalData: edgeData,
|
||||
label: relationType
|
||||
};
|
||||
});
|
||||
|
||||
this.updateD3Graph();
|
||||
this.updateGraphStats();
|
||||
console.log('Graph initialized with', this.nodes.length, 'nodes and', this.links.length, 'links');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching graph data:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
ltmGetUserIds() {
|
||||
axios.get('/api/plug/alkaid/ltm/user_ids')
|
||||
.then(response => {
|
||||
this.userIdList = response.data.data;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching user IDs:', error);
|
||||
});
|
||||
},
|
||||
|
||||
updateGraphStats() {
|
||||
this.graphStats = {
|
||||
nodeCount: this.nodes.length,
|
||||
edgeCount: this.links.length
|
||||
};
|
||||
},
|
||||
|
||||
refreshGraph() {
|
||||
this.ltmGetGraph(this.searchUserId);
|
||||
},
|
||||
|
||||
onNodeSelect() {
|
||||
console.log('Selected user ID:', this.searchUserId);
|
||||
if (!this.searchUserId) return;
|
||||
|
||||
// 使用API的user_id参数筛选数据
|
||||
this.ltmGetGraph(this.searchUserId);
|
||||
},
|
||||
|
||||
resetFilter() {
|
||||
this.searchUserId = null;
|
||||
this.ltmGetGraph();
|
||||
},
|
||||
|
||||
initD3Graph() {
|
||||
const container = document.getElementById("graph-container");
|
||||
if (!container) return;
|
||||
d3.select("#graph-container svg").remove();
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const svg = d3.select("#graph-container")
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.attr("viewBox", [0, 0, width, height])
|
||||
.classed("d3-graph", true);
|
||||
const g = svg.append("g");
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 10])
|
||||
.on("zoom", (event) => {
|
||||
g.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
const simulation = d3.forceSimulation()
|
||||
.force("link", d3.forceLink().id(d => d.id).distance(100))
|
||||
.force("charge", d3.forceManyBody().strength(-300))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collision", d3.forceCollide().radius(30));
|
||||
|
||||
this.svg = svg;
|
||||
this.g = g;
|
||||
this.zoom = zoom;
|
||||
this.simulation = simulation;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
},
|
||||
|
||||
updateD3Graph() {
|
||||
if (!this.svg || !this.simulation) return;
|
||||
const g = this.g;
|
||||
g.selectAll("*").remove();
|
||||
g.append("defs").append("marker")
|
||||
.attr("id", "arrowhead")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 20)
|
||||
.attr("refY", 0)
|
||||
.attr("orient", "auto")
|
||||
.attr("markerWidth", 6)
|
||||
.attr("markerHeight", 6)
|
||||
.append("path")
|
||||
.attr("d", "M0,-5L10,0L0,5")
|
||||
.attr("fill", "#999");
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(this.links)
|
||||
.join("line")
|
||||
.attr("stroke", d => d.color)
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("marker-end", "url(#arrowhead)");
|
||||
const edgeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.links)
|
||||
.join("text")
|
||||
.text(d => d.label)
|
||||
.attr("font-size", "8px")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#666")
|
||||
.attr("dy", -5);
|
||||
const node = g.append("g")
|
||||
.selectAll("circle")
|
||||
.data(this.nodes)
|
||||
.join("circle")
|
||||
.attr("r", 8)
|
||||
.attr("fill", d => d.color)
|
||||
.style("cursor", "pointer")
|
||||
.call(this.dragBehavior());
|
||||
const nodeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.nodes)
|
||||
.join("text")
|
||||
.text(d => d.label)
|
||||
.attr("font-size", "10px")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#333")
|
||||
.attr("dy", -12);
|
||||
node.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.selectedNode = d.originalData;
|
||||
});
|
||||
this.svg.on("click", () => {
|
||||
this.selectedNode = null;
|
||||
});
|
||||
this.simulation
|
||||
.nodes(this.nodes)
|
||||
.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => d.source.x)
|
||||
.attr("y1", d => d.source.y)
|
||||
.attr("x2", d => d.target.x)
|
||||
.attr("y2", d => d.target.y);
|
||||
edgeLabels
|
||||
.attr("x", d => (d.source.x + d.target.x) / 2)
|
||||
.attr("y", d => (d.source.y + d.target.y) / 2);
|
||||
node
|
||||
.attr("cx", d => d.x)
|
||||
.attr("cy", d => d.y);
|
||||
nodeLabels
|
||||
.attr("x", d => d.x)
|
||||
.attr("y", d => d.y);
|
||||
});
|
||||
|
||||
this.simulation.force("link")
|
||||
.links(this.links);
|
||||
|
||||
this.simulation.alpha(1).restart();
|
||||
},
|
||||
|
||||
dragBehavior() {
|
||||
return d3.drag()
|
||||
.on("start", (event, d) => {
|
||||
if (!event.active) this.simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on("drag", (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on("end", (event, d) => {
|
||||
if (!event.active) this.simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
});
|
||||
},
|
||||
|
||||
getRandomColor() {
|
||||
const letters = '0123456789ABCDEF';
|
||||
let color = '#';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -442,27 +70,11 @@ export default {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#graph-container {
|
||||
position: relative;
|
||||
background-color: #f2f6f9;
|
||||
overflow: hidden;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
#graph-container:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
#graph-container svg {
|
||||
#subview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.d3-graph {
|
||||
background-color: #f2f6f9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,641 @@
|
||||
<template>
|
||||
<div class="flex-grow-1" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; padding: 16px">
|
||||
<!-- knowledge card -->
|
||||
<div v-if="kbCollections.length == 0" class="d-flex align-center justify-center flex-column"
|
||||
style="flex-grow: 1; width: 100%; height: 100%;">
|
||||
<h2>还没有知识库,快创建一个吧!🙂</h2>
|
||||
<v-btn style="margin-top: 16px;" variant="tonal" color="primary" @click="showCreateDialog = true">
|
||||
创建知识库
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<h2 class="mb-4">知识库列表</h2>
|
||||
<v-btn class="mb-4" prepend-icon="mdi-plus" variant="tonal" color="primary"
|
||||
@click="showCreateDialog = true">
|
||||
创建新知识库
|
||||
</v-btn>
|
||||
|
||||
<div class="kb-grid">
|
||||
<div v-for="(kb, index) in kbCollections" :key="index" class="kb-card"
|
||||
@click="openKnowledgeBase(kb)">
|
||||
<div class="book-spine"></div>
|
||||
<div class="book-content">
|
||||
<div class="emoji-container">
|
||||
<span class="kb-emoji">{{ kb.emoji || '🙂' }}</span>
|
||||
</div>
|
||||
<div class="kb-name">{{ kb.collection_name }}</div>
|
||||
<div class="kb-count">{{ kb.count || 0 }} 条知识</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建知识库对话框 -->
|
||||
<v-dialog v-model="showCreateDialog" max-width="500px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h4">创建新知识库</v-card-title>
|
||||
<v-card-text>
|
||||
|
||||
<div style="width: 100%; display: flex; align-items: center; justify-content: center;">
|
||||
<span id="emoji-display" @click="showEmojiPicker = true">
|
||||
{{ newKB.emoji || '🙂' }}
|
||||
</span>
|
||||
</div>
|
||||
<v-form @submit.prevent="submitCreateForm">
|
||||
|
||||
|
||||
<v-text-field variant="outlined" v-model="newKB.name" label="知识库名称" required></v-text-field>
|
||||
|
||||
<v-textarea v-model="newKB.description" label="描述" variant="outlined" placeholder="知识库的简短描述..."
|
||||
rows="3"></v-textarea>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="error" variant="text" @click="showCreateDialog = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="text" @click="submitCreateForm">创建</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 表情选择器对话框 -->
|
||||
<v-dialog v-model="showEmojiPicker" max-width="400px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">选择表情</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="emoji-picker">
|
||||
<div v-for="(category, catIndex) in emojiCategories" :key="catIndex" class="mb-4">
|
||||
<div class="text-subtitle-2 mb-2">{{ category.name }}</div>
|
||||
<div class="emoji-grid">
|
||||
<div v-for="(emoji, emojiIndex) in category.emojis" :key="emojiIndex" class="emoji-item"
|
||||
@click="selectEmoji(emoji)">
|
||||
{{ emoji }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" variant="text" @click="showEmojiPicker = false">关闭</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 知识库内容管理对话框 -->
|
||||
<v-dialog v-model="showContentDialog" max-width="1000px" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<div class="me-2 emoji-sm">{{ currentKB.emoji || '🙂' }}</div>
|
||||
<span>{{ currentKB.collection_name }} - 知识库管理</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="plain" icon @click="showContentDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-tabs v-model="activeTab">
|
||||
<v-tab value="upload">上传文件</v-tab>
|
||||
<v-tab value="search">搜索内容</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-window v-model="activeTab" class="mt-4">
|
||||
<!-- 上传文件标签页 -->
|
||||
<v-window-item value="upload">
|
||||
<div class="upload-container pa-4">
|
||||
<div class="text-center mb-4">
|
||||
<h3>上传文件到知识库</h3>
|
||||
<p class="text-subtitle-1">支持 txt、pdf、word、excel 等多种格式</p>
|
||||
</div>
|
||||
|
||||
<div class="upload-zone"
|
||||
@dragover.prevent
|
||||
@drop.prevent="onFileDrop"
|
||||
@click="triggerFileInput">
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
style="display: none"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
|
||||
<p class="mt-2">拖放文件到这里或点击上传</p>
|
||||
</div>
|
||||
|
||||
<div class="selected-files mt-4" v-if="selectedFile">
|
||||
<div type="info" variant="tonal" class="d-flex align-center">
|
||||
<div>
|
||||
<v-icon class="me-2">{{ getFileIcon(selectedFile.name) }}</v-icon>
|
||||
<span style="font-weight: 1000;">{{ selectedFile.name }}</span>
|
||||
</div>
|
||||
<v-btn size="small" color="error" variant="text" @click="selectedFile = null">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
:loading="uploading"
|
||||
:disabled="!selectedFile"
|
||||
@click="uploadFile"
|
||||
>
|
||||
上传到知识库
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-progress mt-4" v-if="uploading">
|
||||
<v-progress-linear
|
||||
indeterminate
|
||||
color="primary"
|
||||
></v-progress-linear>
|
||||
</div>
|
||||
</div>
|
||||
</v-window-item>
|
||||
|
||||
<!-- 搜索内容标签页 -->
|
||||
<v-window-item value="search">
|
||||
<div class="search-container pa-4">
|
||||
<v-form @submit.prevent="searchKnowledgeBase" class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="搜索知识库内容"
|
||||
append-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
class="flex-grow-1 me-2"
|
||||
@click:append="searchKnowledgeBase"
|
||||
@keyup.enter="searchKnowledgeBase"
|
||||
placeholder="输入关键词搜索知识库内容..."
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<v-select
|
||||
v-model="topK"
|
||||
:items="[3, 5, 10, 20]"
|
||||
label="结果数量"
|
||||
variant="outlined"
|
||||
style="max-width: 120px;"
|
||||
hide-details
|
||||
></v-select>
|
||||
</v-form>
|
||||
|
||||
<div class="search-results mt-4">
|
||||
<div v-if="searching">
|
||||
<v-progress-linear indeterminate color="primary"></v-progress-linear>
|
||||
<p class="text-center mt-4">正在搜索...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchResults.length > 0">
|
||||
<h3 class="mb-2">搜索结果</h3>
|
||||
<v-card v-for="(result, index) in searchResults" :key="index"
|
||||
class="mb-4 search-result-card" variant="outlined">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon class="me-2" size="small" color="primary">mdi-file-document-outline</v-icon>
|
||||
<span class="text-caption text-medium-emphasis">{{ result.metadata.source }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-chip v-if="result.score" size="small" color="primary" variant="tonal">
|
||||
相关度: {{ Math.round(result.score * 100) }}%
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="search-content">{{ result.content }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchPerformed">
|
||||
<v-alert type="info" variant="tonal">
|
||||
没有找到匹配的内容
|
||||
</v-alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
|
||||
{{ snackbar.text }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'KnowledgeBase',
|
||||
data() {
|
||||
return {
|
||||
kbCollections: [],
|
||||
showCreateDialog: false,
|
||||
showEmojiPicker: false,
|
||||
newKB: {
|
||||
name: '',
|
||||
emoji: '🙂',
|
||||
description: ''
|
||||
},
|
||||
snackbar: {
|
||||
show: false,
|
||||
text: '',
|
||||
color: 'success'
|
||||
},
|
||||
emojiCategories: [
|
||||
{
|
||||
name: '笑脸和情感',
|
||||
emojis: ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩', '😘']
|
||||
},
|
||||
{
|
||||
name: '动物和自然',
|
||||
emojis: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵']
|
||||
},
|
||||
{
|
||||
name: '食物和饮料',
|
||||
emojis: ['🍏', '🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥']
|
||||
},
|
||||
{
|
||||
name: '活动和物品',
|
||||
emojis: ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🎱', '🏓', '🏸', '🥅', '🏒', '🏑', '🥍']
|
||||
},
|
||||
{
|
||||
name: '旅行和地点',
|
||||
emojis: ['🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑', '🚒', '🚐', '🚚', '🚛', '🚜', '🛴', '🚲']
|
||||
},
|
||||
{
|
||||
name: '符号和旗帜',
|
||||
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗']
|
||||
}
|
||||
],
|
||||
showContentDialog: false,
|
||||
currentKB: {
|
||||
collection_name: '',
|
||||
emoji: ''
|
||||
},
|
||||
activeTab: 'upload',
|
||||
selectedFile: null,
|
||||
uploading: false,
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
searching: false,
|
||||
searchPerformed: false,
|
||||
topK: 5
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.getKBCollections();
|
||||
},
|
||||
methods: {
|
||||
getKBCollections() {
|
||||
axios.get('/api/plug/alkaid/kb/collections')
|
||||
.then(response => {
|
||||
this.kbCollections = response.data.data;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching knowledge base collections:', error);
|
||||
this.showSnackbar('获取知识库列表失败', 'error');
|
||||
});
|
||||
},
|
||||
|
||||
createCollection(name, emoji, description) {
|
||||
axios.post('/api/plug/alkaid/kb/create_collection', {
|
||||
collection_name: name,
|
||||
emoji: emoji,
|
||||
description: description
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSnackbar('知识库创建成功');
|
||||
this.getKBCollections();
|
||||
this.showCreateDialog = false;
|
||||
this.resetNewKB();
|
||||
} else {
|
||||
this.showSnackbar(response.data.message || '创建失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error creating knowledge base collection:', error);
|
||||
this.showSnackbar('创建知识库失败', 'error');
|
||||
});
|
||||
},
|
||||
|
||||
submitCreateForm() {
|
||||
if (!this.newKB.name) {
|
||||
this.showSnackbar('请输入知识库名称', 'warning');
|
||||
return;
|
||||
}
|
||||
this.createCollection(
|
||||
this.newKB.name,
|
||||
this.newKB.emoji || '🙂',
|
||||
this.newKB.description
|
||||
);
|
||||
},
|
||||
|
||||
resetNewKB() {
|
||||
this.newKB = {
|
||||
name: '',
|
||||
emoji: '🙂',
|
||||
description: ''
|
||||
};
|
||||
},
|
||||
|
||||
openKnowledgeBase(kb) {
|
||||
// 不再跳转路由,而是打开对话框
|
||||
this.currentKB = kb;
|
||||
this.showContentDialog = true;
|
||||
this.resetContentDialog();
|
||||
},
|
||||
|
||||
resetContentDialog() {
|
||||
this.activeTab = 'upload';
|
||||
this.selectedFile = null;
|
||||
this.searchQuery = '';
|
||||
this.searchResults = [];
|
||||
this.searchPerformed = false;
|
||||
},
|
||||
|
||||
triggerFileInput() {
|
||||
this.$refs.fileInput.click();
|
||||
},
|
||||
|
||||
onFileSelected(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
this.selectedFile = files[0];
|
||||
}
|
||||
},
|
||||
|
||||
onFileDrop(event) {
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
this.selectedFile = files[0];
|
||||
}
|
||||
},
|
||||
|
||||
getFileIcon(filename) {
|
||||
const extension = filename.split('.').pop().toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return 'mdi-file-pdf-box';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'mdi-file-word-box';
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return 'mdi-file-excel-box';
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'mdi-file-powerpoint-box';
|
||||
case 'txt':
|
||||
return 'mdi-file-document-outline';
|
||||
default:
|
||||
return 'mdi-file-outline';
|
||||
}
|
||||
},
|
||||
|
||||
uploadFile() {
|
||||
if (!this.selectedFile) {
|
||||
this.showSnackbar('请先选择文件', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploading = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', this.selectedFile);
|
||||
formData.append('collection_name', this.currentKB.collection_name);
|
||||
|
||||
axios.post('/api/plug/alkaid/kb/collection/add_file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSnackbar('文件上传成功');
|
||||
this.selectedFile = null;
|
||||
|
||||
// 刷新知识库列表,获取更新的数量
|
||||
this.getKBCollections();
|
||||
} else {
|
||||
this.showSnackbar(response.data.message || '上传失败', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error uploading file:', error);
|
||||
this.showSnackbar('文件上传失败', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
this.uploading = false;
|
||||
});
|
||||
},
|
||||
|
||||
searchKnowledgeBase() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
this.showSnackbar('请输入搜索内容', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.searching = true;
|
||||
this.searchPerformed = true;
|
||||
|
||||
axios.get(`/api/plug/alkaid/kb/collection/search`, {
|
||||
params: {
|
||||
collection_name: this.currentKB.collection_name,
|
||||
query: this.searchQuery,
|
||||
top_k: this.topK
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.searchResults = response.data.data || [];
|
||||
|
||||
if (this.searchResults.length === 0) {
|
||||
this.showSnackbar('没有找到匹配的内容', 'info');
|
||||
}
|
||||
} else {
|
||||
this.showSnackbar(response.data.message || '搜索失败', 'error');
|
||||
this.searchResults = [];
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error searching knowledge base:', error);
|
||||
this.showSnackbar('搜索知识库失败', 'error');
|
||||
this.searchResults = [];
|
||||
})
|
||||
.finally(() => {
|
||||
this.searching = false;
|
||||
});
|
||||
},
|
||||
|
||||
showSnackbar(text, color = 'success') {
|
||||
this.snackbar.text = text;
|
||||
this.snackbar.color = color;
|
||||
this.snackbar.show = true;
|
||||
},
|
||||
|
||||
selectEmoji(emoji) {
|
||||
this.newKB.emoji = emoji;
|
||||
this.showEmojiPicker = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.kb-card {
|
||||
height: 280px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.kb-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.book-spine {
|
||||
width: 12px;
|
||||
background-color: #5c6bc0;
|
||||
height: 100%;
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
.book-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: linear-gradient(145deg, #f5f7fa 0%, #e4e8f0 100%);
|
||||
}
|
||||
|
||||
.emoji-container {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kb-emoji {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.kb-name {
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.kb-count {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.emoji-picker {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
font-size: 24px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.emoji-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
#emoji-display {
|
||||
font-size: 64px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
#emoji-display:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.emoji-sm {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #5c6bc0;
|
||||
background-color: rgba(92, 107, 192, 0.05);
|
||||
}
|
||||
|
||||
.search-container {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.search-result-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.search-result-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.search-content {
|
||||
white-space: pre-line;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
padding: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,610 @@
|
||||
<template>
|
||||
<div id="long-term-memory" class="flex-grow-1" style="display: flex; flex-direction: row; ">
|
||||
<div id="graph-container" style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; max-height: calc(100% - 40px);">
|
||||
</div>
|
||||
<div id="graph-control-panel"
|
||||
style="min-width: 450px; border: 1px solid #eee; border-radius: 8px; padding: 16px; padding-bottom: 0px; margin-left: 16px; max-height: calc(100% - 40px);">
|
||||
<div>
|
||||
<!-- <span style="color: #333333;">可视化</span> -->
|
||||
<h3>筛选</h3>
|
||||
<div style="margin-top: 8px;">
|
||||
<v-autocomplete v-model="searchUserId" density="compact" :items="userIdList" variant="outlined"
|
||||
label="筛选用户 ID"></v-autocomplete>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<v-btn color="primary" @click="onNodeSelect" variant="tonal">
|
||||
<v-icon start>mdi-magnify</v-icon>
|
||||
筛选
|
||||
</v-btn>
|
||||
<v-btn color="secondary" @click="resetFilter" variant="tonal">
|
||||
<v-icon start>mdi-filter-remove</v-icon>
|
||||
重置筛选
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="refreshGraph" variant="tonal">
|
||||
<v-icon start>mdi-refresh</v-icon>
|
||||
刷新图形
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增搜索记忆功能 -->
|
||||
<div class="mt-4">
|
||||
<h3>搜索记忆</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div >
|
||||
<v-text-field
|
||||
v-model="searchMemoryUserId"
|
||||
label="用户 ID"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="输入关键词"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
@keyup.enter="searchMemory"
|
||||
class="mb-2"
|
||||
></v-text-field>
|
||||
<v-btn color="info" @click="searchMemory" :loading="isSearching" variant="tonal">
|
||||
<v-icon start>mdi-text-search</v-icon>
|
||||
搜索
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 新增搜索结果展示区域 -->
|
||||
<div v-if="searchResults.length > 0" class="mt-3">
|
||||
<v-divider class="mb-3"></v-divider>
|
||||
<div class="text-subtitle-1 mb-2">搜索结果 ({{ searchResults.length }})</div>
|
||||
<v-expansion-panels variant="accordion">
|
||||
<v-expansion-panel
|
||||
v-for="(result, index) in searchResults"
|
||||
:key="index"
|
||||
>
|
||||
<v-expansion-panel-title>
|
||||
<div>
|
||||
<span class="text-truncate d-inline-block" style="max-width: 300px;">{{ result.text.substring(0, 30) }}...</span>
|
||||
<span class="ms-2 text-caption text-grey">(相关度: {{ (result.score * 100).toFixed(1) }}%)</span>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<div>
|
||||
<div class="mb-2 text-body-1">{{ result.text }}</div>
|
||||
<div class="d-flex">
|
||||
<span class="text-caption text-grey">文档ID: {{ result.doc_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
<div v-else-if="hasSearched" class="mt-3 text-center text-body-1 text-grey">
|
||||
未找到相关记忆内容
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- 新增添加记忆数据的表单 -->
|
||||
<div class="mt-4">
|
||||
<h3>添加记忆数据</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<v-form @submit.prevent="addMemoryData">
|
||||
<v-textarea
|
||||
v-model="newMemoryText"
|
||||
label="输入文本内容"
|
||||
variant="outlined"
|
||||
rows="4"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
></v-textarea>
|
||||
|
||||
<v-text-field
|
||||
v-model="newMemoryUserId"
|
||||
label="用户 ID"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<v-switch
|
||||
v-model="needSummarize"
|
||||
color="primary"
|
||||
label="需要摘要"
|
||||
hide-details
|
||||
></v-switch>
|
||||
|
||||
<v-btn
|
||||
color="success"
|
||||
type="submit"
|
||||
:loading="isSubmitting"
|
||||
:disabled="!newMemoryText || !newMemoryUserId"
|
||||
>
|
||||
<v-icon start>mdi-plus</v-icon>
|
||||
添加数据
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNode" class="mt-4">
|
||||
<h3>节点详情</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div v-if="selectedNode.id">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">ID:</span>
|
||||
<span>{{ selectedNode.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode._label">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">类型:</span>
|
||||
<span>{{ selectedNode._label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.name">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">名称:</span>
|
||||
<span>{{ selectedNode.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.user_id">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">用户ID:</span>
|
||||
<span>{{ selectedNode.user_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.ts">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">时间戳:</span>
|
||||
<span>{{ selectedNode.ts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.type">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">类型:</span>
|
||||
<span>{{ selectedNode.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-if="graphStats" class="mt-4">
|
||||
<h3>图形统计</h3>
|
||||
<v-card variant="outlined" class="mt-2 pa-3">
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">节点数:</span>
|
||||
<span>{{ graphStats.nodeCount }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-space-between">
|
||||
<span class="text-subtitle-2">边数:</span>
|
||||
<span>{{ graphStats.edgeCount }}</span>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import * as d3 from "d3"; // npm install d3
|
||||
|
||||
export default {
|
||||
name: 'LongTermMemory',
|
||||
data() {
|
||||
return {
|
||||
simulation: null,
|
||||
svg: null,
|
||||
zoom: null,
|
||||
node_data: [],
|
||||
edge_data: [],
|
||||
nodes: [],
|
||||
links: [],
|
||||
searchUserId: null,
|
||||
userIdList: [],
|
||||
selectedNode: null,
|
||||
graphStats: null,
|
||||
nodeColors: {
|
||||
'PhaseNode': '#4CAF50', // 绿色
|
||||
'PassageNode': '#2196F3', // 蓝色
|
||||
'FactNode': '#FF9800', // 橙色
|
||||
'default': '#9C27B0' // 紫色作为默认
|
||||
},
|
||||
edgeColors: {
|
||||
'_include_': '#607D8B',
|
||||
'_related_': '#9E9E9E',
|
||||
'default': '#BDBDBD'
|
||||
},
|
||||
isLoading: false,
|
||||
// 添加新的数据属性
|
||||
newMemoryText: '',
|
||||
newMemoryUserId: null,
|
||||
needSummarize: false,
|
||||
isSubmitting: false,
|
||||
// 搜索记忆相关属性
|
||||
searchMemoryUserId: null,
|
||||
searchQuery: '',
|
||||
isSearching: false,
|
||||
searchResults: [],
|
||||
hasSearched: false,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initD3Graph();
|
||||
this.ltmGetGraph();
|
||||
this.ltmGetUserIds();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.simulation) {
|
||||
this.simulation.stop();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 添加搜索记忆方法
|
||||
searchMemory() {
|
||||
if (!this.searchQuery.trim()) {
|
||||
this.$toast.warning('请输入搜索关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSearching = true;
|
||||
this.hasSearched = true;
|
||||
this.searchResults = [];
|
||||
|
||||
// 构建查询参数
|
||||
const params = {
|
||||
query: this.searchQuery
|
||||
};
|
||||
|
||||
// 如果有选择用户ID,也加入查询参数
|
||||
if (this.searchMemoryUserId) {
|
||||
params.user_id = this.searchMemoryUserId;
|
||||
}
|
||||
|
||||
axios.get('/api/plug/alkaid/ltm/graph/search', { params })
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
const data = response.data.data;
|
||||
|
||||
// 处理返回的文档数组
|
||||
this.searchResults = Object.keys(data).map(doc_id => {
|
||||
return {
|
||||
doc_id: doc_id,
|
||||
text: data[doc_id].text || '无文本内容',
|
||||
score: data[doc_id].score || 0
|
||||
};
|
||||
});
|
||||
|
||||
if (this.searchResults.length === 0) {
|
||||
this.$toast.info('未找到相关记忆内容');
|
||||
} else {
|
||||
this.$toast.success(`找到 ${this.searchResults.length} 条相关记忆`);
|
||||
}
|
||||
} else {
|
||||
this.$toast.error('搜索失败: ' + response.data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('搜索记忆数据失败:', error);
|
||||
this.$toast.error('搜索失败: ' + (error.response?.data?.message || error.message));
|
||||
})
|
||||
.finally(() => {
|
||||
this.isSearching = false;
|
||||
});
|
||||
},
|
||||
|
||||
// 添加新方法,用于提交记忆数据
|
||||
addMemoryData() {
|
||||
if (!this.newMemoryText || !this.newMemoryUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
|
||||
// 准备提交数据
|
||||
const payload = {
|
||||
text: this.newMemoryText,
|
||||
user_id: this.newMemoryUserId,
|
||||
need_summarize: this.needSummarize
|
||||
};
|
||||
|
||||
axios.post('/api/plug/alkaid/ltm/graph/add', payload)
|
||||
.then(response => {
|
||||
// 成功添加后刷新图表
|
||||
this.refreshGraph();
|
||||
|
||||
// 重置表单
|
||||
// this.newMemoryText = '';
|
||||
// this.needSummarize = false;
|
||||
|
||||
// 显示成功消息
|
||||
this.$toast.success('记忆数据添加成功!');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('添加记忆数据失败:', error);
|
||||
this.$toast.error('添加记忆数据失败: ' + (error.response?.data?.message || error.message));
|
||||
})
|
||||
.finally(() => {
|
||||
this.isSubmitting = false;
|
||||
});
|
||||
},
|
||||
|
||||
ltmGetGraph(userId = null) {
|
||||
this.isLoading = true;
|
||||
const params = userId ? { user_id: userId } : {};
|
||||
|
||||
axios.get('/api/plug/alkaid/ltm/graph', { params })
|
||||
.then(response => {
|
||||
let nodesRaw = response.data.data.nodes;
|
||||
let edgesRaw = response.data.data.edges;
|
||||
|
||||
this.node_data = nodesRaw;
|
||||
this.edge_data = edgesRaw;
|
||||
|
||||
// 转换为D3所需的数据格式
|
||||
this.nodes = nodesRaw.map(node => {
|
||||
const nodeId = node[0];
|
||||
const nodeData = node[1];
|
||||
const nodeType = nodeData._label || 'default';
|
||||
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
|
||||
|
||||
return {
|
||||
id: nodeId,
|
||||
label: nodeData.name || nodeId.split('_')[0],
|
||||
color: color,
|
||||
originalData: nodeData
|
||||
};
|
||||
});
|
||||
|
||||
this.links = edgesRaw.map(edge => {
|
||||
const sourceId = edge[0];
|
||||
const targetId = edge[1];
|
||||
const edgeData = edge[2];
|
||||
const relationType = edgeData.relation_type || 'default';
|
||||
const color = this.edgeColors[relationType] || this.edgeColors['default'];
|
||||
|
||||
return {
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
color: color,
|
||||
originalData: edgeData,
|
||||
label: relationType
|
||||
};
|
||||
});
|
||||
|
||||
this.updateD3Graph();
|
||||
this.updateGraphStats();
|
||||
console.log('Graph initialized with', this.nodes.length, 'nodes and', this.links.length, 'links');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching graph data:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
ltmGetUserIds() {
|
||||
axios.get('/api/plug/alkaid/ltm/user_ids')
|
||||
.then(response => {
|
||||
this.userIdList = response.data.data;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching user IDs:', error);
|
||||
});
|
||||
},
|
||||
|
||||
updateGraphStats() {
|
||||
this.graphStats = {
|
||||
nodeCount: this.nodes.length,
|
||||
edgeCount: this.links.length
|
||||
};
|
||||
},
|
||||
|
||||
refreshGraph() {
|
||||
this.ltmGetGraph(this.searchUserId);
|
||||
},
|
||||
|
||||
onNodeSelect() {
|
||||
console.log('Selected user ID:', this.searchUserId);
|
||||
if (!this.searchUserId) return;
|
||||
|
||||
// 使用API的user_id参数筛选数据
|
||||
this.ltmGetGraph(this.searchUserId);
|
||||
},
|
||||
|
||||
resetFilter() {
|
||||
this.searchUserId = null;
|
||||
this.searchQuery = ''; // 重置搜索关键词
|
||||
this.searchResults = []; // 清空搜索结果
|
||||
this.hasSearched = false; // 重置搜索状态
|
||||
this.ltmGetGraph();
|
||||
},
|
||||
|
||||
initD3Graph() {
|
||||
const container = document.getElementById("graph-container");
|
||||
if (!container) return;
|
||||
d3.select("#graph-container svg").remove();
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
const svg = d3.select("#graph-container")
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.attr("viewBox", [0, 0, width, height])
|
||||
.classed("d3-graph", true);
|
||||
const g = svg.append("g");
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.1, 10])
|
||||
.on("zoom", (event) => {
|
||||
g.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoom);
|
||||
const simulation = d3.forceSimulation()
|
||||
.force("link", d3.forceLink().id(d => d.id).distance(100))
|
||||
.force("charge", d3.forceManyBody().strength(-300))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force("collision", d3.forceCollide().radius(30));
|
||||
|
||||
this.svg = svg;
|
||||
this.g = g;
|
||||
this.zoom = zoom;
|
||||
this.simulation = simulation;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
},
|
||||
|
||||
updateD3Graph() {
|
||||
if (!this.svg || !this.simulation) return;
|
||||
const g = this.g;
|
||||
g.selectAll("*").remove();
|
||||
g.append("defs").append("marker")
|
||||
.attr("id", "arrowhead")
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 20)
|
||||
.attr("refY", 0)
|
||||
.attr("orient", "auto")
|
||||
.attr("markerWidth", 6)
|
||||
.attr("markerHeight", 6)
|
||||
.append("path")
|
||||
.attr("d", "M0,-5L10,0L0,5")
|
||||
.attr("fill", "#999");
|
||||
const link = g.append("g")
|
||||
.selectAll("line")
|
||||
.data(this.links)
|
||||
.join("line")
|
||||
.attr("stroke", d => d.color)
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("marker-end", "url(#arrowhead)");
|
||||
const edgeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.links)
|
||||
.join("text")
|
||||
.text(d => d.label)
|
||||
.attr("font-size", "8px")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#666")
|
||||
.attr("dy", -5);
|
||||
const node = g.append("g")
|
||||
.selectAll("circle")
|
||||
.data(this.nodes)
|
||||
.join("circle")
|
||||
.attr("r", 8)
|
||||
.attr("fill", d => d.color)
|
||||
.style("cursor", "pointer")
|
||||
.call(this.dragBehavior());
|
||||
const nodeLabels = g.append("g")
|
||||
.selectAll("text")
|
||||
.data(this.nodes)
|
||||
.join("text")
|
||||
.text(d => d.label)
|
||||
.attr("font-size", "10px")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#333")
|
||||
.attr("dy", -12);
|
||||
node.on("click", (event, d) => {
|
||||
event.stopPropagation();
|
||||
this.selectedNode = d.originalData;
|
||||
});
|
||||
this.svg.on("click", () => {
|
||||
this.selectedNode = null;
|
||||
});
|
||||
this.simulation
|
||||
.nodes(this.nodes)
|
||||
.on("tick", () => {
|
||||
link
|
||||
.attr("x1", d => d.source.x)
|
||||
.attr("y1", d => d.source.y)
|
||||
.attr("x2", d => d.target.x)
|
||||
.attr("y2", d => d.target.y);
|
||||
edgeLabels
|
||||
.attr("x", d => (d.source.x + d.target.x) / 2)
|
||||
.attr("y", d => (d.source.y + d.target.y) / 2);
|
||||
node
|
||||
.attr("cx", d => d.x)
|
||||
.attr("cy", d => d.y);
|
||||
nodeLabels
|
||||
.attr("x", d => d.x)
|
||||
.attr("y", d => d.y);
|
||||
});
|
||||
|
||||
this.simulation.force("link")
|
||||
.links(this.links);
|
||||
|
||||
this.simulation.alpha(1).restart();
|
||||
},
|
||||
|
||||
dragBehavior() {
|
||||
return d3.drag()
|
||||
.on("start", (event, d) => {
|
||||
if (!event.active) this.simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on("drag", (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on("end", (event, d) => {
|
||||
if (!event.active) this.simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
});
|
||||
},
|
||||
|
||||
getRandomColor() {
|
||||
const letters = '0123456789ABCDEF';
|
||||
let color = '#';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
color += letters[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#long-term-memory {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#graph-container {
|
||||
position: relative;
|
||||
background-color: #f2f6f9;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#graph-control-panel {
|
||||
height: 100%;
|
||||
overflow-y: auto; /* 让控制面板可滚动而不是整个页面滚动 */
|
||||
min-width: 450px;
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
#graph-container:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memory-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
#graph-container svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.d3-graph {
|
||||
background-color: #f2f6f9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="flex-grow-1" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<div class="d-flex align-center justify-center"
|
||||
style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px;">
|
||||
<span size="64">🌍</span>
|
||||
<p class="text-h6 text-grey ml-4">前面的世界,以后再来探索吧!</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'OtherFeatures'
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user